diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 47a6404..85e5509 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2864,6 +2864,92 @@ public static class AgentHarness Emit(cmd, ok: true, detail: "story_cleared"); break; + case "interest_saga_clear": + LifeSagaRoster.Clear(); + LifeSagaRail.Clear(); + _cmdOk++; + Emit(cmd, ok: true, detail: "saga_cleared"); + break; + + case "interest_story_purge_leftovers": + StoryPlanner.PurgeCloserLeftovers(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"purged crisisActive={StoryPlanner.CrisisActive} softQuiet={StoryPlanner.SoftFillQuietActive}"); + break; + + case "story_resume_parked": + { + var board = new System.Collections.Generic.List(4); + StoryPlanner.CopyBoard(board); + string id = ""; + for (int i = 0; i < board.Count; i++) + { + if (board[i] != null && board[i].IsParked) + { + id = board[i].Id ?? ""; + break; + } + } + + bool ok = !string.IsNullOrEmpty(id) && StoryPlanner.TryResume(id); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, + detail: + $"resumeOk={ok} id='{id}' watching='{StoryPlanner.Active?.Id}' board={StoryPlanner.BoardCount}"); + break; + } + + case "saga_prefer_focus": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + long id = EventFeedUtil.SafeId(focus); + LifeSagaRoster.Tick(Time.unscaledTime); + bool ok = id != 0 && LifeSagaRoster.IsMc(id) && LifeSagaRoster.TogglePrefer(id); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, + detail: + $"preferToggle id={id} onRoster={LifeSagaRoster.IsMc(id)} prefer={LifeSagaRoster.IsPrefer(id)}"); + break; + } + + case "saga_force_admit_focus": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + long id = EventFeedUtil.SafeId(focus); + bool ok = LifeSagaRoster.HarnessForceAdmit(focus); + if (ok) + { + LifeSagaRoster.StampChapter(id, "Harness chapter", Time.unscaledTime); + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"admit id={id} roster={LifeSagaRoster.Count} on={ok}"); + break; + } + case "interest_variety_note": { // Stamp the current (or pending needle in value) as the last shown variety arc. @@ -3417,6 +3503,9 @@ public static class AgentHarness if (Queue.Count == 0) { + // Free AFK after harness must not inherit a leftover crisis closer tip. + StoryPlanner.PurgeCloserLeftovers(); + LifeSagaRoster.Clear(); string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL"; WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}"); LogService.LogInfo( @@ -7758,7 +7847,118 @@ public static class AgentHarness string have = arc != null ? arc.Phase.ToString() : "None"; pass = !string.IsNullOrEmpty(want) && string.Equals(have, want, StringComparison.OrdinalIgnoreCase); - detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}"; + detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold} board={StoryPlanner.BoardCount}"; + break; + } + case "story_board_count": + { + int want = ParseInt(cmd.value, 0); + int have = StoryPlanner.BoardCount; + string cmp = (cmd.label ?? "").Trim().ToLowerInvariant(); + if (cmp == "min") + { + pass = have >= want; + } + else if (cmp == "max") + { + pass = have <= want; + } + else + { + pass = have == want; + } + + detail = $"boardCount={have} want={want} cmp={cmp}"; + break; + } + case "story_parked_count": + { + int want = ParseInt(cmd.value, 0); + var board = new System.Collections.Generic.List(4); + StoryPlanner.CopyBoard(board); + int parked = 0; + for (int i = 0; i < board.Count; i++) + { + if (board[i] != null && board[i].IsParked) + { + parked++; + } + } + + string cmp = (cmd.label ?? "").Trim().ToLowerInvariant(); + pass = cmp == "min" ? parked >= want : parked == want; + detail = $"parked={parked} want={want} board={board.Count}"; + break; + } + case "browse_paused": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = SpectatorMode.BrowsePaused; + pass = have == want; + detail = $"browsePaused={have} want={want} idle={SpectatorMode.Active}"; + break; + } + case "saga_roster_count": + case "story_rail_count": + { + LifeSagaRoster.Tick(Time.unscaledTime); + LifeSagaRail.Refresh(); + int want = ParseInt(cmd.value, 0); + int have = LifeSagaRoster.Count; + int shown = LifeSagaRail.LastShownCount; + string cmp = (cmd.label ?? "").Trim().ToLowerInvariant(); + string expectKey = (cmd.expect ?? "").Trim().ToLowerInvariant(); + int probe = expectKey == "story_rail_count" ? shown : have; + pass = cmp == "min" ? probe >= want : probe == want; + detail = $"roster={have} railShown={shown} want={want} cmp={cmp} visible={LifeSagaRail.Visible}"; + break; + } + case "saga_overview_tip": + case "story_rail_tip": + { + string have = SampleRailTip(); + string any = (cmd.value ?? "").Trim(); + pass = false; + if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any)) + { + string[] parts = any.Split('|'); + for (int i = 0; i < parts.Length; i++) + { + string needle = (parts[i] ?? "").Trim(); + if (needle.Length > 0 + && have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = true; + break; + } + } + } + + detail = $"sagaTip='{have}' any='{any}' pass={pass}"; + break; + } + case "saga_overview_tip_not": + case "story_rail_tip_not": + { + string have = SampleRailTip(); + string any = (cmd.value ?? "").Trim(); + pass = true; + if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any)) + { + string[] parts = any.Split('|'); + for (int i = 0; i < parts.Length; i++) + { + string needle = (parts[i] ?? "").Trim(); + if (needle.Length > 0 + && have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + pass = false; + break; + } + } + } + + detail = $"railTip='{have}' not_any='{any}' pass={pass}"; break; } case "crisis_active": @@ -7770,6 +7970,14 @@ public static class AgentHarness detail = $"crisisActive={have} want={want} kind={crisis?.Kind} phase={crisis?.Phase}"; break; } + case "soft_fill_quiet": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = StoryPlanner.SoftFillQuietActive; + pass = have == want; + detail = $"softFillQuiet={have} want={want}"; + break; + } case "crisis_kind": { string want = (cmd.value ?? "").Trim(); @@ -7894,11 +8102,21 @@ public static class AgentHarness break; } case "ledger_has_focus": + case "saga_has_focus": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; long id = EventFeedUtil.SafeId(focus); - pass = id != 0 && CharacterLedger.IsOnLedger(id); - detail = $"ledger focus={SafeName(focus)} id={id} on={pass} heat={CharacterLedger.Heat(id):0.##}"; + LifeSagaRoster.Tick(Time.unscaledTime); + pass = id != 0 && LifeSagaRoster.IsMc(id); + detail = $"saga focus={SafeName(focus)} id={id} on={pass} prefer={LifeSagaRoster.IsPrefer(id)}"; + break; + } + case "saga_prefer_focus_on": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + long id = EventFeedUtil.SafeId(focus); + pass = id != 0 && LifeSagaRoster.IsPrefer(id); + detail = $"prefer focus={SafeName(focus)} id={id} on={pass}"; break; } case "tip_matches_unit": @@ -11945,6 +12163,26 @@ public static class AgentHarness return defaultValue; } + private static string SampleRailTip() + { + LifeSagaRoster.Tick(Time.unscaledTime); + LifeSagaRail.Refresh(); + string have = LifeSagaRail.LastTipSample ?? ""; + if (!string.IsNullOrEmpty(have)) + { + return have; + } + + var slots = new System.Collections.Generic.List(10); + LifeSagaRoster.CopySlots(slots); + if (slots.Count > 0 && slots[0] != null) + { + return LifeSagaOverview.Build(slots[0]) ?? ""; + } + + return ""; + } + private static bool ParseBool(string raw, bool defaultValue) { if (string.IsNullOrEmpty(raw)) diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index 8149582..a7a25c7 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -888,7 +888,7 @@ public static class ChronicleHud if (pauseIdle && SpectatorMode.Active) { _pausedIdleForLore = true; - SpectatorMode.SetActive(false, quiet: true); + SpectatorMode.SetActive(false, quiet: true, browsePause: true); } else if (pauseIdle) { @@ -1015,12 +1015,12 @@ public static class ChronicleHud _followFocus = followFocus; _detailPane = DetailPane.Life; - // Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that - // would instantly drop the camera off the browse subject. + // Pause idle first: browsePause keeps StoryPlanner / registry for Lore close resume. + // SetActive(false) still ClearFollow() so focus the browse subject after. if (pauseIdle && SpectatorMode.Active) { _pausedIdleForLore = true; - SpectatorMode.SetActive(false, quiet: true); + SpectatorMode.SetActive(false, quiet: true, browsePause: true); } else if (pauseIdle) { @@ -2283,7 +2283,7 @@ public static class ChronicleHud { if (SpectatorMode.Active) { - SpectatorMode.SetActive(false, quiet: true); + SpectatorMode.SetActive(false, quiet: true, browsePause: true); WatchCaption.PinWhilePaused(); WatchCaption.ShowStatusBanner( captured.Kind == ChronicleKind.World || captured.IsLegend diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Disaster.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Disaster.cs index 6c6fc48..4097d30 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Disaster.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Disaster.cs @@ -85,5 +85,25 @@ public static partial class EventCatalog IsFallback = true }; } + + /// + /// True when is an authored overlay or a live + /// asset (not tip-string heuristics). + /// + public static bool IsLiveOrAuthored(string disasterId) + { + if (string.IsNullOrEmpty(disasterId)) + { + return false; + } + + string id = disasterId.Trim(); + if (HasAuthored(id)) + { + return true; + } + + return ActivityAssetCatalog.TryGetDisasterAsset(id) != null; + } } } diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs b/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs index 3ebf11c..86031bb 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs @@ -182,9 +182,11 @@ public static partial class EventCatalog string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim(); float strength = 40f; string category = "WorldLog"; - if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0) + // Live disaster library / authored disaster overlays - not tip-string matching. + if (Disaster.IsLiveOrAuthored(id)) { - strength = 95f; + DisasterInterestEntry d = Disaster.GetOrFallback(id); + strength = d.EventStrength > 0f ? d.EventStrength : 95f; category = "Disaster"; } @@ -202,6 +204,23 @@ public static partial class EventCatalog }; } + /// WorldLog / disaster tips classified as Disaster via catalog or live library. + public static bool IsDisasterCategory(string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + return false; + } + + if (TryGet(assetId, out WorldLogEventEntry entry) + && string.Equals(entry.Category, "Disaster", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return Disaster.IsLiveOrAuthored(assetId); + } + public static bool TryGetEventStrength(string assetId, out float eventStrength) { WorldLogEventEntry entry = GetOrFallback(assetId); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 97bc84a..69f8d81 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -119,6 +119,9 @@ internal static class HarnessScenarios return StoryDecisionIntentLive(); case "story_aftermath_war": return StoryAftermathWar(); + case "story_crisis_hygiene": + case "crisis_hygiene": + return StoryCrisisHygiene(); case "story_crisis_war": case "crisis_war": return StoryCrisisWar(); @@ -133,10 +136,27 @@ internal static class HarnessScenarios return StoryNearTie(); case "story_arc_combat": return StoryArcCombat(); + case "story_lore_pause_keeps": + case "lore_pause_keeps_story": + return StoryLorePauseKeeps(); + case "story_board_park_resume": + case "story_park_resume": + return StoryBoardParkResume(); case "story_arc_preempt_resume": return StoryArcPreemptResume(); case "story_ledger_revisit": - return StoryLedgerRevisit(); + // Retired with CharacterLedger - soft MC bias lives in saga_* scenarios. + return SagaCameraPrefersMc(); + case "saga_roster_diversity": + return SagaRosterDiversity(); + case "saga_camera_prefers_mc": + return SagaCameraPrefersMc(); + case "saga_replace_on_death": + return SagaReplaceOnDeath(); + case "saga_prefer_soft_bias": + return SagaPreferSoftBias(); + case "saga_overview_has_mc": + return SagaOverviewHasMc(); case "story_spine_scoped": return StorySpineScoped(); case "story_family_variety": @@ -318,9 +338,17 @@ internal static class HarnessScenarios Nested("reg_combat_wiped_side", "combat_wiped_side"), Nested("reg_story_near_tie", "story_near_tie"), Nested("reg_story_arc", "story_arc_combat"), + Nested("reg_story_lore_pause", "story_lore_pause_keeps"), + Nested("reg_story_board_park", "story_board_park_resume"), + Nested("reg_story_aftermath_war", "story_aftermath_war"), + Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"), Nested("reg_story_spine", "story_spine_scoped"), Nested("reg_story_family", "story_family_variety"), - Nested("reg_story_ledger", "story_ledger_revisit"), + Nested("reg_saga_camera", "saga_camera_prefers_mc"), + Nested("reg_saga_prefer", "saga_prefer_soft_bias"), + Nested("reg_saga_overview", "saga_overview_has_mc"), + Nested("reg_saga_diversity", "saga_roster_diversity"), + Nested("reg_saga_death", "saga_replace_on_death"), Nested("reg_discovery", "discovery"), Nested("reg_worldlog", "world_log"), Nested("reg_presentability", "camera_presentability"), @@ -1702,11 +1730,57 @@ internal static class HarnessScenarios Step("saw24", "director_run", wait: 1.5f), Step("saw25", "assert", expect: "tip_matches_any", value: "lingers|stands over|war front"), Step("saw26", "assert", expect: "tip_asset", value: "aftermath_"), + // Large war auto-opens a crisis; short-arc linger must quiet-close it (no dual closer). + Step("saw27", "assert", expect: "crisis_active", value: "false"), + Step("saw28", "assert", expect: "soft_fill_quiet", value: "true"), + Step("saw29", "director_run", wait: 2.0f), + Step("saw30", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"), Step("saw90", "fast_timing", value: "false"), Step("saw99", "snapshot"), }; } + /// + /// Large WarFront opens crisis; short-arc war linger owns the exit; purge leaves no + /// pending epilogue_crisis for free AFK. + /// + private static List StoryCrisisHygiene() + { + return new List + { + Step("sch0", "dismiss_windows"), + Step("sch1", "wait_world"), + Step("sch2", "set_setting", expect: "enabled", value: "true"), + Step("sch3", "fast_timing", value: "true"), + Step("sch4", "spawn", asset: "human", count: 2), + Step("sch5", "spectator", value: "off"), + Step("sch6", "spectator", value: "on"), + Step("sch7", "pick_unit", asset: "human"), + Step("sch8", "focus", asset: "human"), + Step("sch9", "interest_end_session"), + Step("sch10", "interest_story_clear"), + Step("sch20", "interest_war_session", asset: "human", expect: "sch_war"), + Step("sch21", "director_run", wait: 0.8f), + Step("sch22", "assert", expect: "tip_matches_any", value: "War -"), + // Live 12v8 sides open crisis without harness force. + Step("sch23", "assert", expect: "crisis_active", value: "true"), + Step("sch24", "assert", expect: "crisis_kind", value: "War"), + Step("sch30", "interest_expire_pending", value: ""), + Step("sch31", "interest_mark_sticky_cold"), + Step("sch32", "director_run", wait: 1.5f), + Step("sch33", "assert", expect: "tip_asset", value: "aftermath_"), + Step("sch34", "assert", expect: "crisis_active", value: "false"), + Step("sch35", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"), + Step("sch36", "assert", expect: "soft_fill_quiet", value: "true"), + Step("sch40", "interest_story_purge_leftovers"), + Step("sch41", "assert", expect: "crisis_active", value: "false"), + Step("sch42", "director_run", wait: 1.0f), + Step("sch43", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"), + Step("sch90", "fast_timing", value: "false"), + Step("sch99", "snapshot"), + }; + } + /// /// Large WarFront opens a crisis chapter; forced closer is epilogue_crisis. /// @@ -2573,6 +2647,95 @@ internal static class HarnessScenarios } /// Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath. + /// + /// Lore browse freeze must not wipe StoryPlanner; close resumes the same climax. + /// + private static List StoryLorePauseKeeps() + { + return new List + { + Step("slp0", "dismiss_windows"), + Step("slp1", "wait_world"), + Step("slp2", "set_setting", expect: "enabled", value: "true"), + Step("slp3", "fast_timing", value: "true"), + Step("slp4", "spawn", asset: "human", count: 2), + Step("slp5", "spectator", value: "off"), + Step("slp6", "spectator", value: "on"), + Step("slp7", "pick_unit", asset: "human"), + Step("slp8", "focus", asset: "human"), + Step("slp9", "interest_end_session"), + Step("slp10", "interest_story_clear"), + Step("slp20", "interest_war_session", asset: "human", expect: "slp_war"), + Step("slp21", "director_run", wait: 0.6f), + Step("slp22", "assert", expect: "tip_matches_any", value: "War -"), + Step("slp23", "assert", expect: "story_phase", value: "Climax"), + Step("slp24", "assert", expect: "story_board_count", value: "1", label: "min"), + Step("slp30", "lore_open_focus"), + Step("slp31", "assert", expect: "idle", value: "false"), + Step("slp32", "assert", expect: "browse_paused", value: "true"), + Step("slp33", "assert", expect: "story_phase", value: "Climax"), + Step("slp34", "assert", expect: "story_board_count", value: "1", label: "min"), + Step("slp40", "lore_close"), + Step("slp41", "wait", wait: 0.25f), + Step("slp42", "assert", expect: "idle", value: "true"), + Step("slp43", "assert", expect: "browse_paused", value: "false"), + Step("slp44", "assert", expect: "story_phase", value: "Climax"), + Step("slp45", "assert", expect: "tip_matches_any", value: "War -"), + Step("slp90", "fast_timing", value: "false"), + Step("slp99", "snapshot"), + }; + } + + /// + /// Hard war storyline parks when forced off-story; returning to War tip resumes the board slot. + /// + private static List StoryBoardParkResume() + { + return new List + { + Step("sbp0", "dismiss_windows"), + Step("sbp1", "wait_world"), + Step("sbp2", "set_setting", expect: "enabled", value: "true"), + Step("sbp3", "fast_timing", value: "true"), + Step("sbp4", "spawn", asset: "human", count: 2), + Step("sbp5", "spectator", value: "off"), + Step("sbp6", "spectator", value: "on"), + Step("sbp7", "pick_unit", asset: "human"), + Step("sbp8", "focus", asset: "human"), + Step("sbp9", "interest_end_session"), + Step("sbp10", "interest_story_clear"), + Step("sbp20", "interest_war_session", asset: "human", expect: "sbp_war"), + Step("sbp21", "director_run", wait: 0.6f), + Step("sbp22", "assert", expect: "tip_matches_any", value: "War -"), + Step("sbp23", "assert", expect: "story_phase", value: "Climax"), + Step("sbp23b", "director_run", wait: 0.3f), + // Force an unrelated tip (war hold blocks ordinary epic inject cut-ins). + Step("sbp30", "interest_force_session", asset: "human", + label: "ParkProbe", tier: "Epic", expect: "sbp_park_probe", + value: "lead=event;evt=150"), + Step("sbp31", "assert", expect: "tip_contains", value: "ParkProbe"), + Step("sbp32", "assert", expect: "story_parked_count", value: "1", label: "min"), + Step("sbp33", "assert", expect: "story_board_count", value: "1", label: "min"), + // Resume parked short arc without rail Commit (rail is life-saga now). + Step("sbp40", "story_resume_parked"), + Step("sbp41", "assert", expect: "story_parked_count", value: "0"), + Step("sbp42", "assert", expect: "story_phase", value: "Climax"), + Step("sbp43", "assert", expect: "story_board_count", value: "1"), + // A second distinct war theater parks the first and watches the new climax. + Step("sbp50", "interest_force_session", asset: "human", + label: "ParkProbe2", tier: "Epic", expect: "sbp_park_probe2", + value: "lead=event;evt=150"), + Step("sbp51", "assert", expect: "story_parked_count", value: "1", label: "min"), + Step("sbp52", "interest_war_session", asset: "human", expect: "sbp_war2"), + Step("sbp53", "director_run", wait: 1.0f), + Step("sbp54", "assert", expect: "tip_matches_any", value: "War -"), + Step("sbp55", "assert", expect: "story_phase", value: "Climax"), + Step("sbp56", "assert", expect: "story_board_count", value: "2", label: "min"), + Step("sbp90", "fast_timing", value: "false"), + Step("sbp99", "snapshot"), + }; + } + private static List StoryArcCombat() { return new List @@ -2588,6 +2751,9 @@ internal static class HarnessScenarios Step("sac8", "pick_unit", asset: "human"), Step("sac9", "focus", asset: "human"), Step("sac10", "interest_end_session"), + Step("sac10b", "interest_story_clear"), + Step("sac10c", "interest_variety_clear"), + Step("sac10d", "interest_expire_pending", value: ""), Step("sac20", "interest_combat_session", asset: "human", value: "wolf", expect: "sac_pack"), Step("sac20b", "combat_isolate_pair", value: "20"), Step("sac21", "status_apply", asset: "human", value: "invincible"), @@ -2596,7 +2762,10 @@ internal static class HarnessScenarios Step("sac24", "assert", expect: "story_phase", value: "Climax"), Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"), Step("sac25", "interest_expire_pending", value: ""), + // Survivor aftermath requires a fallen theater partner (both-alive must not mint stands-over). + Step("sac25b", "combat_kill_related"), Step("sac26", "age_current", wait: 10f), + Step("sac26b", "interest_expire_pending", value: ""), Step("sac27", "interest_mark_combat_cold"), Step("sac28", "director_run", wait: 1.5f), Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"), @@ -2706,39 +2875,154 @@ internal static class HarnessScenarios }; } - /// Featured unit wins a near-tie against a stranger milestone. - private static List StoryLedgerRevisit() + /// MC on the life-saga roster wins a near-tie against a stranger tip. + private static List SagaCameraPrefersMc() { return new List { - Step("sl0", "dismiss_windows"), - Step("sl1", "wait_world"), - Step("sl2", "set_setting", expect: "enabled", value: "true"), - Step("sl3", "fast_timing", value: "true"), - Step("sl4", "spawn", asset: "human", count: 2), - Step("sl5", "spectator", value: "off"), - Step("sl6", "spectator", value: "on"), - Step("sl7", "pick_unit", asset: "human"), - Step("sl8", "focus", asset: "human"), - Step("sl9", "interest_end_session"), - // Feature unit A via a watched tip. - Step("sl10", "interest_inject", asset: "human", label: "FeaturedLife", tier: "Action", - expect: "sl_feat", value: "lead=event;evt=70;char=5;force=false;ttl=60"), - Step("sl11", "director_run", wait: 1.2f), - Step("sl12", "assert", expect: "tip_contains", value: "FeaturedLife"), - Step("sl13", "assert", expect: "ledger_has_focus"), - Step("sl14", "interest_end_session"), - Step("sl15", "interest_expire_pending", value: ""), - // Near-tie: same strength on focus vs other human - ledger/causal heat should tip focus. - Step("sl20", "interest_inject", asset: "human", label: "LedgerWin", tier: "Action", - expect: "sl_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"), - Step("sl21", "interest_inject", asset: "human", label: "LedgerLose", tier: "Action", - expect: "sl_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"), - Step("sl22", "director_run", wait: 1.2f), - Step("sl23", "assert", expect: "tip_contains", value: "LedgerWin"), - Step("sl24", "assert", expect: "tip_not_contains", value: "LedgerLose"), - Step("sl90", "fast_timing", value: "false"), - Step("sl99", "snapshot"), + Step("scm0", "dismiss_windows"), + Step("scm1", "wait_world"), + Step("scm2", "set_setting", expect: "enabled", value: "true"), + Step("scm3", "fast_timing", value: "true"), + Step("scm4", "spawn", asset: "human", count: 2), + Step("scm5", "spectator", value: "off"), + Step("scm6", "spectator", value: "on"), + Step("scm7", "pick_unit", asset: "human"), + Step("scm8", "focus", asset: "human"), + Step("scm9", "interest_end_session"), + Step("scm10", "interest_saga_clear"), + Step("scm11", "saga_force_admit_focus"), + Step("scm12", "assert", expect: "saga_has_focus"), + Step("scm13", "interest_expire_pending", value: ""), + Step("scm20", "interest_inject", asset: "human", label: "SagaMcWin", tier: "Action", + expect: "scm_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"), + Step("scm21", "interest_inject", asset: "human", label: "SagaMcLose", tier: "Action", + expect: "scm_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"), + Step("scm22", "director_run", wait: 1.2f), + Step("scm23", "assert", expect: "tip_contains", value: "SagaMcWin"), + Step("scm24", "assert", expect: "tip_not_contains", value: "SagaMcLose"), + Step("scm90", "fast_timing", value: "false"), + Step("scm99", "snapshot"), + }; + } + + /// Prefer toggle soft-biases a near-tie toward the Prefer'd MC. + private static List SagaPreferSoftBias() + { + return new List + { + Step("spb0", "dismiss_windows"), + Step("spb1", "wait_world"), + Step("spb2", "set_setting", expect: "enabled", value: "true"), + Step("spb3", "fast_timing", value: "true"), + Step("spb4", "spawn", asset: "human", count: 2), + Step("spb5", "spectator", value: "off"), + Step("spb6", "spectator", value: "on"), + Step("spb7", "pick_unit", asset: "human"), + Step("spb8", "focus", asset: "human"), + Step("spb9", "interest_end_session"), + Step("spb10", "interest_saga_clear"), + Step("spb11", "saga_force_admit_focus"), + Step("spb12", "saga_prefer_focus"), + Step("spb13", "assert", expect: "saga_prefer_focus_on"), + Step("spb14", "interest_expire_pending", value: ""), + Step("spb20", "interest_inject", asset: "human", label: "PreferWin", tier: "Action", + expect: "spb_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"), + Step("spb21", "interest_inject", asset: "human", label: "PreferLose", tier: "Action", + expect: "spb_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"), + Step("spb22", "director_run", wait: 1.2f), + Step("spb23", "assert", expect: "tip_contains", value: "PreferWin"), + Step("spb24", "assert", expect: "tip_not_contains", value: "PreferLose"), + Step("spb90", "fast_timing", value: "false"), + Step("spb99", "snapshot"), + }; + } + + /// Saga overview hover has MC name and no planner jargon / trait descs. + private static List SagaOverviewHasMc() + { + return new List + { + Step("som0", "dismiss_windows"), + Step("som1", "wait_world"), + Step("som2", "set_setting", expect: "enabled", value: "true"), + Step("som3", "fast_timing", value: "true"), + Step("som4", "spawn", asset: "human", count: 1), + Step("som5", "spectator", value: "off"), + Step("som6", "spectator", value: "on"), + Step("som7", "pick_unit", asset: "human"), + Step("som8", "focus", asset: "human"), + Step("som9", "interest_saga_clear"), + Step("som10", "saga_force_admit_focus"), + Step("som11", "director_run", wait: 0.4f), + Step("som12", "assert", expect: "saga_roster_count", value: "1", label: "min"), + Step("som13", "assert", expect: "saga_overview_tip_not", + value: "Climax|Aftermath|Epilogue|parked|watching|Known for:|Appearance brings comfort"), + Step("som14", "assert", expect: "story_rail_count", value: "1", label: "min"), + Step("som90", "fast_timing", value: "false"), + Step("som99", "snapshot"), + }; + } + + /// Multi-species notables fill distinct roster slots. + private static List SagaRosterDiversity() + { + return new List + { + Step("srd0", "dismiss_windows"), + Step("srd1", "wait_world"), + Step("srd2", "set_setting", expect: "enabled", value: "true"), + Step("srd3", "fast_timing", value: "true"), + Step("srd4", "spawn", asset: "human", count: 1), + Step("srd5", "spawn", asset: "elf", count: 1), + Step("srd6", "spawn", asset: "dwarf", count: 1), + Step("srd7", "spectator", value: "off"), + Step("srd8", "spectator", value: "on"), + Step("srd9", "interest_saga_clear"), + Step("srd10", "pick_unit", asset: "human"), + Step("srd11", "focus", asset: "human"), + Step("srd12", "saga_force_admit_focus"), + Step("srd13", "pick_unit", asset: "elf"), + Step("srd14", "focus", asset: "elf"), + Step("srd15", "saga_force_admit_focus"), + Step("srd16", "pick_unit", asset: "dwarf"), + Step("srd17", "focus", asset: "dwarf"), + Step("srd18", "saga_force_admit_focus"), + Step("srd19", "assert", expect: "saga_roster_count", value: "3", label: "min"), + Step("srd90", "fast_timing", value: "false"), + Step("srd99", "snapshot"), + }; + } + + /// Dead MC leaves the living roster; a new living unit can refill the slot. + private static List SagaReplaceOnDeath() + { + return new List + { + Step("srdth0", "dismiss_windows"), + Step("srdth1", "wait_world"), + Step("srdth2", "set_setting", expect: "enabled", value: "true"), + Step("srdth3", "fast_timing", value: "true"), + // Single MC so auto-refill cannot mask the death prune. + Step("srdth4", "spawn", asset: "human", count: 1), + Step("srdth5", "spectator", value: "off"), + Step("srdth6", "spectator", value: "on"), + Step("srdth7", "pick_unit", asset: "human"), + Step("srdth8", "focus", asset: "human"), + Step("srdth9", "interest_saga_clear"), + Step("srdth10", "saga_force_admit_focus"), + Step("srdth11", "assert", expect: "saga_roster_count", value: "1"), + Step("srdth12", "kill_focus"), + Step("srdth13", "wait", wait: 0.3f), + Step("srdth14", "director_run", wait: 0.8f), + Step("srdth15", "assert", expect: "saga_roster_count", value: "0"), + Step("srdth16", "spawn", asset: "human", count: 1), + Step("srdth17", "pick_unit", asset: "human"), + Step("srdth18", "focus", asset: "human"), + Step("srdth19", "saga_force_admit_focus"), + Step("srdth20", "assert", expect: "saga_roster_count", value: "1"), + Step("srdth90", "fast_timing", value: "false"), + Step("srdth99", "snapshot"), }; } diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs new file mode 100644 index 0000000..0277fa3 --- /dev/null +++ b/IdleSpectator/InterestDirector.StickyMaintain.cs @@ -0,0 +1,1930 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +public static partial class InterestDirector +{ + /// + /// Keep camera on the highest-scored combat participant and rewrite tip/counts + /// from a live so subject + scale always match focus. + /// Clears fight Labels once combat goes cold. + /// + private static void MaintainCombatFocus(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) + { + return; + } + + bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now); + if (!combatHot) + { + ClearStaleCombatLabel(now); + return; + } + + float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold + ? CombatFocusThrottleLargeSeconds + : CombatFocusThrottleSeconds; + if (!force && now - _lastCombatFocusAt < throttle) + { + return; + } + + _lastCombatFocusAt = now; + ApplyCombatEnsembleToCurrent(forceWatch: false); + } + + /// + /// Refresh active combat scene from the world ensemble. Returns false if no focus. + /// + private static bool ApplyCombatEnsembleToCurrent(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) + { + return false; + } + + // Snapshot durable pair ownership before cluster rebuild can rewrite Follow+Related. + ResolveCombatPairActors(_current, out Actor ownedFocus, out Actor ownedRelated); + if (ownedFocus != null) + { + _current.StampCombatPair(ownedFocus, ownedRelated); + } + + // NamedPair / living Duel ownership holds the pair. Do not gate on AssetId==live_battle + // or peak alone - ambient nearby scraps polluted those flags and disabled pair hold so + // the tip thrashed onto strangers while CombatStillActive stayed hot forever. + bool pairOwned = ownedFocus != null + && ownedFocus.isAlive() + && ownedRelated != null + && ownedRelated.isAlive() + && IsNamedPairCombatOwnership(_current); + + // Fast path: living NamedPair skips sticky Refresh (ambient camps were flipping Duels). + // Probe the local scrap first so a real sided multi can escalate Duel → Skirmish/Battle/Mass. + // Skip when Follow was cleared (death / harness handoff) so Related can take ownership. + if (pairOwned + && _current.HasFollowUnit + && ownedFocus != null + && ownedFocus.isAlive() + && ownedRelated != null + && ownedRelated.isAlive()) + { + LiveEnsemble.TryBuildCombat( + ownedFocus.current_position, + 14f, + out LiveEnsemble escalateProbe, + priorParticipantIds: _current.ParticipantIds, + newcomerBonus: 8f); + int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2; + bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus); + bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated); + int stickyCampN = _current.CombatSideACount + _current.CombatSideBCount; + // Sticky camps enrolled (pack wire / live growth) outrank NamedPair hold even when + // the live probe still looks like a thin Duel this tick. + bool stickyPackEscalate = HasStickyCollectiveMulti(_current) + && stickyCampN >= 3 + && (focusFighting || relatedFighting); + // Both left the scrap (sleep / chores): keep NamedPair framing and do NOT fall + // through to ambient fighter pick. Absorbing nearby bears into Duel - Honya vs Waf + // refreshed combat hotness forever after the real pair went cold (Neen soak). + if (!focusFighting && !relatedFighting) + { + string idleLabel = ""; + Actor idleFocus = ownedFocus; + Actor idleFoe = ownedRelated; + int idleFighters = 2; + ApplyNamedPairHold( + _current, ownedFocus, ownedRelated, ref idleFocus, ref idleFoe, ref idleFighters, ref idleLabel); + string priorIdle = _current.Label ?? ""; + if (string.IsNullOrEmpty(idleLabel)) + { + idleLabel = priorIdle; + } + + bool idleFollowChanged = _current.FollowUnit != idleFocus; + bool idleLabelChanged = !string.Equals(priorIdle, idleLabel ?? "", StringComparison.Ordinal); + _current.FollowUnit = idleFocus; + _current.SubjectId = EventFeedUtil.SafeId(idleFocus); + _current.RelatedUnit = idleFoe; + _current.RelatedId = idleFoe != null ? EventFeedUtil.SafeId(idleFoe) : 0; + _current.Label = idleLabel; + try + { + _current.Position = idleFocus.current_position; + } + catch + { + // keep + } + + bool idleNeedWatch = forceWatch + || idleFollowChanged + || idleLabelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != idleFocus; + if (idleNeedWatch) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + StoryPlanner.SyncActiveClimax(_current); + return true; + } + + if (!stickyPackEscalate + && !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) + { + string pairLabel = ""; + Actor holdFocus = ownedFocus; + Actor holdFoe = ownedRelated; + int holdFighters = 2; + ApplyNamedPairHold( + _current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel); + string priorLabel = _current.Label ?? ""; + if (string.IsNullOrEmpty(pairLabel)) + { + pairLabel = priorLabel; + } + + bool holdFollowChanged = _current.FollowUnit != holdFocus; + bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal); + _current.FollowUnit = holdFocus; + _current.SubjectId = EventFeedUtil.SafeId(holdFocus); + _current.RelatedUnit = holdFoe; + _current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0; + _current.Label = pairLabel; + try + { + _current.Position = holdFocus.current_position; + } + catch + { + // keep + } + + bool holdNeedWatch = forceWatch + || holdFollowChanged + || holdLabelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != holdFocus; + if (holdNeedWatch) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + StoryPlanner.SyncActiveClimax(_current); + return true; + } + + // Real multi around the pair: fall through to sticky rebuild / collective tip. + } + + Vector3 pos = _current.Position; + if (ownedFocus != null && ownedFocus.isAlive()) + { + pos = ownedFocus.current_position; + } + else if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) + { + pos = _current.FollowUnit.current_position; + } + else if (ownedRelated != null && ownedRelated.isAlive()) + { + pos = ownedRelated.current_position; + } + else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) + { + pos = _current.RelatedUnit.current_position; + } + + bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + || _current.ParticipantCount > 2 + || HasStickyCombatSides(_current); + float radius = massHint ? 14f : 10f; + + // Death / clear-follow handoff: promote Related before cluster re-pick can steal focus. + if (!_current.HasFollowUnit + && _current.RelatedUnit != null + && _current.RelatedUnit.isAlive()) + { + Actor handoff = _current.RelatedUnit; + Actor handoffFoe = null; + try + { + if (handoff.has_attack_target + && handoff.attack_target != null + && handoff.attack_target.isAlive() + && handoff.attack_target.isActor()) + { + handoffFoe = handoff.attack_target.a; + if (handoffFoe != null && !handoffFoe.isAlive()) + { + handoffFoe = null; + } + } + } + catch + { + handoffFoe = null; + } + + LiveEnsemble.TryBuildCombat( + handoff.current_position, + radius, + out LiveEnsemble handoffEnsemble, + priorParticipantIds: _current.ParticipantIds, + newcomerBonus: 8f); + if (handoffEnsemble != null) + { + StickyScoreboard.StabilizeScale(_current.Sticky, handoffEnsemble, Time.unscaledTime); + ApplyEnsembleFields(_current, handoffEnsemble); + StickyScoreboard.Refresh( + _current, handoffEnsemble, handoff.current_position, radius); + } + + // Keep ownership on the handoff unit; only use ensemble framing when the melee is large. + string handoffLabel; + if (handoffEnsemble != null + && (handoffEnsemble.ParticipantCount > 2 + || StickyScoreboard.HasOpposingSides(_current) + || handoffEnsemble.Scale != EnsembleScale.Pair)) + { + handoffEnsemble.Focus = handoff; + if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff) + { + handoffEnsemble.Related = handoffFoe; + } + + handoffLabel = EventReason.Combat(handoffEnsemble); + } + else + { + // NamedPair handoff: keep Duel framing with the other pair id when possible. + if (handoffFoe == null || !handoffFoe.isAlive()) + { + long handoffId = EventFeedUtil.SafeId(handoff); + long otherId = _current.PairOwnerId != 0 && _current.PairOwnerId != handoffId + ? _current.PairOwnerId + : _current.PairPartnerId; + if (otherId != 0 && otherId != handoffId) + { + handoffFoe = LiveEnsemble.FindTrackedActor(otherId); + } + } + + var pairHandoff = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = EnsembleScale.Pair, + Frame = EnsembleFrame.NamedPair, + ParticipantCount = 2, + Focus = handoff, + Related = handoffFoe + }; + handoffLabel = EventReason.Combat(pairHandoff); + if (string.IsNullOrEmpty(handoffLabel)) + { + handoffLabel = EventReason.Fight(handoff, handoffFoe); + } + + _current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount); + } + + bool handoffFollowChanged = _current.FollowUnit != handoff; + bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal); + _current.FollowUnit = handoff; + _current.SubjectId = EventFeedUtil.SafeId(handoff); + _current.RelatedUnit = handoffFoe; + _current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0; + _current.StampCombatPair(handoff, handoffFoe); + _current.Label = handoffLabel; + try + { + _current.Position = handoff.current_position; + } + catch + { + // keep + } + + if (forceWatch + || handoffFollowChanged + || handoffLabelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != handoff) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + StoryPlanner.SyncActiveClimax(_current); + return true; + } + + LiveEnsemble.TryBuildCombat( + pos, + radius, + out LiveEnsemble ensemble, + priorParticipantIds: _current.ParticipantIds, + newcomerBonus: 8f); + + Actor best; + Actor foe; + int fighters; + string label; + if (ensemble != null && ensemble.HasFocus) + { + StickyScoreboard.StabilizeScale(_current.Sticky, ensemble, Time.unscaledTime); + best = ensemble.Focus; + foe = ensemble.Related; + fighters = Mathf.Max(0, ensemble.ParticipantCount); + ApplyEnsembleFields(_current, ensemble); + StickyScoreboard.Refresh(_current, ensemble, pos, radius); + label = EventReason.Combat(ensemble); + } + else if (WorldActivityScanner.TryPickBestCombatFocus( + _current, out best, out foe, out fighters)) + { + var fallback = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = LiveEnsemble.ScaleForCount(fighters), + Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly, + ParticipantCount = fighters, + Focus = best, + Related = foe + }; + StickyScoreboard.StabilizeScale(_current.Sticky, fallback, Time.unscaledTime); + ApplyEnsembleFields(_current, fallback); + StickyScoreboard.Refresh(_current, fallback, pos, radius); + label = EventReason.Combat(fallback); + _current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount); + } + else if (StickyScoreboard.HasOpposingSides(_current)) + { + // No live fighters briefly - keep framed tip from sticky roster until combat goes cold. + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = 0 + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, radius); + if (held.Focus == null || !held.Focus.isAlive()) + { + held.Focus = _current.RelatedUnit; + } + + if ((held.Focus == null || !held.Focus.isAlive()) && StickyScoreboard.TryPromoteFollow(_current)) + { + held.Focus = _current.FollowUnit; + held.Related = _current.RelatedUnit; + StickyScoreboard.Refresh(_current, held, pos, radius); + } + + if (held.Focus == null || !held.Focus.isAlive()) + { + return false; + } + + best = held.Focus; + foe = held.Related; + label = EventReason.Combat(held); + fighters = _current.CombatSideACount + _current.CombatSideBCount; + } + else + { + return false; + } + + // Prefer the ownership snapshot over any Follow/Related rewrite during Refresh. + Actor curFollow = ownedFocus != null && ownedFocus.isAlive() + ? ownedFocus + : _current.FollowUnit; + Actor curRelated = ownedRelated != null && ownedRelated.isAlive() + ? ownedRelated + : _current.RelatedUnit; + + // Named-pair lock: ambient nearby camps must not steal / flip a living Duel. + // Only a true multi escalate (or intentional sticky Battle) may leave the pair. + if (pairOwned + && curFollow != null + && curFollow.isAlive() + && !ShouldEscalateNamedPair(_current, ensemble, fighters)) + { + ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); + } + else + { + // Keep PairOwner/Partner across escalate. Sticky multi already disables NamedPair + // hold; wiping the lock made Mass collapse into Duel vs the latest attack_target. + + // Collective Mass/Battle: hold the theater lead (best across both sides). + // Attack-target gaps used to hop the camera across the mob every maintain tick. + if (TryApplyTheaterLeadHold(_current, ref best, ref foe, fighters)) + { + // Theater lead owns Follow/Related for this maintain. + } + else + { + // Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores). + bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); + bool bestFighting = LiveEnsemble.IsCombatParticipant(best); + if (!curFighting) + { + if (bestFighting) + { + foe = ResolveAttackFoe(best) ?? foe; + } + else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe)) + { + best = fighter; + foe = fighterFoe ?? foe; + if (ensemble != null) + { + label = EventReason.Combat(ensemble); + } + } + else if (curFollow != null && curFollow.isAlive()) + { + // Nobody fighting - keep ownership rather than jump to a random sleeper. + best = curFollow; + foe = ResolveAttackFoe(best) ?? curRelated ?? foe; + } + } + else if (!bestFighting) + { + best = curFollow; + foe = ResolveAttackFoe(best) ?? foe; + } + else if (best != curFollow + && curFollow != null + && curFollow.isAlive() + && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) + { + float curW = LiveEnsemble.FocusWeight( + curFollow, _current.ParticipantIds, newcomerBonus: 0f); + float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f); + if (newW < curW + CombatFocusSwitchMargin) + { + best = curFollow; + if (foe == null || foe == best) + { + foe = ResolveAttackFoe(best) ?? foe; + } + } + } + + // Preserve duel partner when rebuild briefly loses Related (attack_target gaps). + bool duelLabeled = !string.IsNullOrEmpty(_current.Label) + && _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + if (foe == null + && curRelated != null + && curRelated.isAlive() + && curRelated != best + && (duelLabeled || _current.ForceActive || fighters <= 2)) + { + foe = curRelated; + } + + // Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks. + if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) + { + ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); + } + } + } + + // Prefer sticky collective tips over thin Fight / leftover Duel prose. + // Live probes often collapse to NamedPair when AI drops pack attack_targets. + int stickyFighters = _current.CombatSideACount + _current.CombatSideBCount; + bool stickyCollectiveTip = HasStickyCombatSides(_current) + && (HasStickyCollectiveMulti(_current) || stickyFighters >= 3); + if (HasStickyCombatSides(_current) + && (string.IsNullOrEmpty(label) + || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0 + || (stickyCollectiveTip + && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)))) + { + // Keep theater-lead side first so Mass tip order does not flip every reframe. + BuildStickyCombatTipEnsemble(_current, best, foe, out LiveEnsemble stickyEns); + label = EventReason.Combat(stickyEns); + } + + string previousLabel = _current.Label ?? ""; + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel)) + { + label = previousLabel; + } + + // Never drop the duel partner while the tip is still a Duel - empty Related + // makes the next maintain flip ownership to a thin "X is fighting" tip. + if (foe == null + && curRelated != null + && curRelated.isAlive() + && curRelated != best + && !string.IsNullOrEmpty(label) + && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) + { + foe = curRelated; + } + + // Living pair lock owns NamedPair tip wording across attack_target thrash. + // Theater Mass/Battle may keep a fresh foe for framing; Duel must not hop partners. + if (TryRestoreLockedDuelPartner( + _current, curFollow, curRelated, ref best, ref foe, ref label)) + { + // Locked partner restored. + } + else if (TryHoldOwnerAfterPartnerDeath( + _current, curFollow, ensemble, ref best, ref foe, ref fighters, ref label)) + { + // Owner still fighting after first partner died - no revolving Duel names. + } + + bool followChanged = _current.FollowUnit != best; + bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); + bool framingOnly = labelChanged + && (EventReason.IsHeadcountOnlyChange(previousLabel, label ?? "") + || EventReason.IsCombatFramingOnlyChange(previousLabel, label ?? "")); + _current.FollowUnit = best; + _current.SubjectId = EventFeedUtil.SafeId(best); + _current.RelatedUnit = foe; + _current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0; + _current.Label = label; + try + { + _current.Position = best.current_position; + } + catch + { + // keep prior position + } + + // Headcount / side-order / Battle↔Mass reframes: keep Label live, skip Watch + // when the camera subject is already correct (avoids tip-log + caption churn). + bool needWatch = forceWatch + || followChanged + || (labelChanged && !framingOnly) + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != best; + if (needWatch) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + // Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo). + StoryPlanner.SyncActiveClimax(_current); + return true; + } + + /// + /// Refresh sticky war tip (kingdom populations + follow) while WarFront completion is hot. + /// + private static bool MaintainWarFront(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.WarFront) + { + return false; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyWarFrontMaintain(forceWatch: force); + } + + private static bool ApplyWarFrontMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.WarFront) + { + return false; + } + + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.WarFront, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), + Frame = EnsembleFrame.KingdomVsKingdom, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.WarTheaterRadius); + _current.LastSeenAt = Time.unscaledTime; + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + string label = EventReason.WarFront(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_war"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + /// + /// Refresh sticky plot tip (plotter roster + target kingdom) while PlotActive is hot. + /// + private static bool MaintainPlotCell(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) + { + return false; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyPlotCellMaintain(forceWatch: force); + } + + private static bool ApplyPlotCellMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) + { + return false; + } + + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.PlotCell, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), + Frame = EnsembleFrame.KingdomVsKingdom, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount, + SideA = new EnsembleSide + { + Key = _current.CombatSideAKey, + Display = "Plotters", + Count = _current.CombatSideACount, + Best = _current.FollowUnit + }, + SideB = new EnsembleSide + { + Key = _current.CombatSideBKey, + Display = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), + KingdomDisplay = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), + Count = _current.CombatSideBCount, + Best = _current.RelatedUnit + } + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.PlotTheaterRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + // Plot stickies are groups only - never "Plot - Plotters (1)" / (0). + int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; + if (plotters < 2) + { + _current.Label = ""; + _current.AssetId = "live_plot"; + return _current.HasFollowUnit; + } + + string label = EventReason.PlotCell(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_plot"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + /// + /// Refresh sticky family pack tip (roster + alpha promote) while FamilyPack is hot. + /// + private static bool MaintainFamilyPack(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) + { + return false; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyFamilyPackMaintain(forceWatch: force); + } + + private static bool ApplyFamilyPackMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) + { + return false; + } + + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.FamilyPack, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), + Frame = EnsembleFrame.SpeciesVsSpecies, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount, + SideA = new EnsembleSide + { + Key = _current.CombatSideAKey, + Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) + ? LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(_current.FollowUnit)) + : _current.Sticky.SideADisplay, + Count = _current.CombatSideACount, + Best = _current.FollowUnit + } + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.FamilyTheaterRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + string label = EventReason.FamilyPack(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_family"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + /// + /// Refresh sticky status outbreak tip while StatusOutbreak completion is hot. + /// + private static bool MaintainStatusOutbreak(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) + { + return false; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return _current.HasFollowUnit; + } + + _lastCombatFocusAt = now; + return ApplyStatusOutbreakMaintain(forceWatch: force); + } + + private static bool ApplyStatusOutbreakMaintain(bool forceWatch) + { + if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) + { + return false; + } + + if (!_current.HasFollowUnit) + { + StickyScoreboard.TryPromoteFollow(_current); + } + + if (!_current.HasFollowUnit) + { + return false; + } + + Vector3 pos = _current.Position; + try + { + pos = _current.FollowUnit.current_position; + } + catch + { + // keep + } + + string statusId = _current.StatusId ?? ""; + if (string.IsNullOrEmpty(statusId) + && LiveEnsemble.IsStatusOutbreakSideKey(_current.CombatSideAKey)) + { + statusId = _current.CombatSideAKey.Substring("status:".Length); + _current.StatusId = statusId; + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.StatusOutbreak, + Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), + Frame = EnsembleFrame.SpeciesVsSpecies, + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = _current.CombatSideACount, + SideA = new EnsembleSide + { + Key = string.IsNullOrEmpty(_current.CombatSideAKey) + ? "status:" + statusId + : _current.CombatSideAKey, + Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) + ? LiveEnsemble.StatusDisplayName(statusId) + : _current.Sticky.SideADisplay, + Count = _current.CombatSideACount, + Best = _current.FollowUnit + } + }; + StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); + StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.StatusOutbreakRadius); + if (held.Focus != null && held.Focus.isAlive()) + { + _current.FollowUnit = held.Focus; + _current.SubjectId = EventFeedUtil.SafeId(held.Focus); + } + + if (held.Related != null && held.Related.isAlive()) + { + _current.RelatedUnit = held.Related; + _current.RelatedId = EventFeedUtil.SafeId(held.Related); + } + + // Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1). + int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; + if (carriers < 2) + { + _current.Label = ""; + _current.AssetId = "live_outbreak"; + return _current.HasFollowUnit; + } + + string label = EventReason.StatusOutbreak(held); + if (string.IsNullOrEmpty(label)) + { + label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); + } + + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) + { + label = _current.Label; + } + + bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.Label = label ?? ""; + _current.AssetId = "live_outbreak"; + try + { + if (_current.FollowUnit != null) + { + _current.Position = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); + if (needWatch && _current.HasFollowUnit) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return _current.HasFollowUnit; + } + + /// + /// Hold the collective theater lead through attack gaps, sticky rebuilds, and tip + /// reframes; retarget only on death, leaving the theater, or a clearly hotter fighter. + /// + private static bool TryApplyTheaterLeadHold( + InterestCandidate scene, + ref Actor best, + ref Actor foe, + int fighters) + { + if (scene == null) + { + return false; + } + + float now = Time.unscaledTime; + Actor held = null; + if (scene.TheaterLeadId != 0) + { + held = LiveEnsemble.FindTrackedActor(scene.TheaterLeadId); + } + + if (held == null || !held.isAlive()) + { + held = scene.FollowUnit != null && scene.FollowUnit.isAlive() + ? scene.FollowUnit + : null; + } + + bool hasDurableLead = held != null && held.isAlive() && scene.TheaterLeadId != 0; + if (!IsCollectiveCombatTheater(scene) && !hasDurableLead) + { + if (fighters < 3 || !HasStickyCombatSides(scene)) + { + return false; + } + + if (IsNamedPairCombatOwnership(scene)) + { + return false; + } + } + + if (IsNamedPairCombatOwnership(scene) && !hasDurableLead) + { + return false; + } + + if (held != null && LiveEnsemble.IsCombatParticipant(held)) + { + scene.TheaterLeadLastCombatAt = now; + } + + bool onRoster = held != null && StickyScoreboard.IsOnRoster(scene, held); + bool nearTheater = held != null && IsNearCombatTheater(scene, held); + bool fighting = held != null && LiveEnsemble.IsCombatParticipant(held); + bool inGrace = held != null + && held.isAlive() + && scene.TheaterLeadId != 0 + && EventFeedUtil.SafeId(held) == scene.TheaterLeadId + && now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds; + // Must be fighting or within post-fight grace. Roster/near only keep a real lead + // (chore bystanders parked on Follow must not inherit grace and stick the camera). + bool holdOk = held != null + && held.isAlive() + && (fighting || inGrace) + && (onRoster || nearTheater || fighting); + + if (!TryPickTheaterLead(scene, out Actor pick, out Actor pickFoe)) + { + if (!holdOk) + { + return false; + } + + best = held; + foe = ResolveAttackFoe(held) ?? OppositeSideBest(scene, held) ?? scene.RelatedUnit ?? foe; + scene.StampTheaterLead(held); + return true; + } + + if (holdOk) + { + float heldW = TheaterLeadScore( + scene, + held, + SideCountForActor(scene, held), + SideCountForOpponent(scene, held)); + float pickW = TheaterLeadScore( + scene, + pick, + SideCountForActor(scene, pick), + SideCountForOpponent(scene, pick)); + bool sameSide = SameStickySide(scene, held, pick); + float margin = sameSide + ? CombatTheaterLeadSameSideSwitchMargin + : CombatTheaterLeadSwitchMargin; + // Spectacle theater leads (evil mage, dragon, …) keep the camera harder. + if (WorldActivityScanner.IsSpectaclePublic(held)) + { + margin = Mathf.Max(margin, CombatTheaterLeadSameSideSwitchMargin); + } + + // Non-spectacle lead vs outnumbered spectacle challenger: steal easily so a + // crowned civilian cannot keep the camera through a 1vN mage fight. + bool pickSpectacleThin = !WorldActivityScanner.IsSpectaclePublic(held) + && WorldActivityScanner.IsSpectaclePublic(pick) + && SideCountForActor(scene, pick) > 0 + && SideCountForActor(scene, pick) + < SideCountForOpponent(scene, pick); + if (pickSpectacleThin) + { + margin = Mathf.Min(margin, 8f); + } + + bool steal = pick != held + && LiveEnsemble.IsCombatParticipant(pick) + && pickW >= heldW + margin; + if (!steal) + { + best = held; + foe = ResolveAttackFoe(held) + ?? OppositeSideBest(scene, held) + ?? pickFoe + ?? foe; + scene.StampTheaterLead(held); + return true; + } + } + + best = pick; + foe = ResolveAttackFoe(pick) ?? pickFoe ?? foe; + scene.StampTheaterLead(pick); + if (LiveEnsemble.IsCombatParticipant(pick)) + { + scene.TheaterLeadLastCombatAt = now; + } + + return true; + } + + private static bool IsNearCombatTheater(InterestCandidate scene, Actor actor) + { + if (scene == null || actor == null || !actor.isAlive()) + { + return false; + } + + try + { + Vector3 anchor = scene.Position; + if (scene.FollowUnit != null && scene.FollowUnit.isAlive() && scene.FollowUnit != actor) + { + // Prefer sticky scrap anchor when follow already hopped. + } + + float dx = actor.current_position.x - anchor.x; + float dy = actor.current_position.y - anchor.y; + float r = CombatTheaterLeadNearRadius; + return dx * dx + dy * dy <= r * r; + } + catch + { + return false; + } + } + + private static bool SameStickySide(InterestCandidate scene, Actor a, Actor b) + { + if (scene?.Sticky == null || a == null || b == null) + { + return false; + } + + long idA = EventFeedUtil.SafeId(a); + long idB = EventFeedUtil.SafeId(b); + if (idA == 0 || idB == 0) + { + return false; + } + + LiveSceneStickyState sticky = scene.Sticky; + bool aOnA = false; + bool aOnB = false; + bool bOnA = false; + bool bOnB = false; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == idA) + { + aOnA = true; + } + + if (sticky.SideAIds[i] == idB) + { + bOnA = true; + } + } + + for (int i = 0; i < sticky.SideBIds.Count; i++) + { + if (sticky.SideBIds[i] == idA) + { + aOnB = true; + } + + if (sticky.SideBIds[i] == idB) + { + bOnB = true; + } + } + + return (aOnA && bOnA) || (aOnB && bOnB); + } + + private static int SideCountForActor(InterestCandidate scene, Actor actor) + { + if (scene?.Sticky == null || actor == null) + { + return 1; + } + + long id = EventFeedUtil.SafeId(actor); + LiveSceneStickyState sticky = scene.Sticky; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == id) + { + return Mathf.Max(1, sticky.SideACount); + } + } + + for (int i = 0; i < sticky.SideBIds.Count; i++) + { + if (sticky.SideBIds[i] == id) + { + return Mathf.Max(1, sticky.SideBCount); + } + } + + return Mathf.Max(1, sticky.SideACount); + } + + private static int SideCountForOpponent(InterestCandidate scene, Actor actor) + { + if (scene?.Sticky == null || actor == null) + { + return 1; + } + + long id = EventFeedUtil.SafeId(actor); + LiveSceneStickyState sticky = scene.Sticky; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == id) + { + return Mathf.Max(1, sticky.SideBCount); + } + } + + return Mathf.Max(1, sticky.SideACount); + } + + private static Actor OppositeSideBest(InterestCandidate scene, Actor actor) + { + if (scene?.Sticky == null || actor == null) + { + return null; + } + + long id = EventFeedUtil.SafeId(actor); + LiveSceneStickyState sticky = scene.Sticky; + bool onA = false; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == id) + { + onA = true; + break; + } + } + + return LiveEnsemble.FindBestAliveTracked( + onA ? sticky.SideBIds : sticky.SideAIds, + preferCombat: true); + } + + /// + /// Pick a living sticky-roster fighter when the current follow left the scrap. + /// + private static bool TryRetargetCombatFollow( + InterestCandidate scene, + out Actor fighter, + out Actor foe) + { + fighter = null; + foe = null; + if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) + { + return false; + } + + LiveSceneStickyState sticky = scene.Sticky; + Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true); + Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true); + bool aFighting = LiveEnsemble.IsCombatParticipant(pickA); + bool bFighting = LiveEnsemble.IsCombatParticipant(pickB); + if (!aFighting && !bFighting) + { + return false; + } + + if (aFighting && bFighting) + { + float wA = LiveEnsemble.FocusWeight(pickA, scene.ParticipantIds, 0f); + float wB = LiveEnsemble.FocusWeight(pickB, scene.ParticipantIds, 0f); + if (wB > wA) + { + fighter = pickB; + foe = pickA; + } + else + { + fighter = pickA; + foe = pickB; + } + } + else if (aFighting) + { + fighter = pickA; + foe = pickB; + } + else + { + fighter = pickB; + foe = pickA; + } + + return fighter != null && fighter.isAlive(); + } + + /// + /// Allow leaving NamedPair only when a real sided multi owns the scrap + /// (species/kingdom camps with 3+ fighters), never from ambient radius noise. + /// + private static bool ShouldEscalateNamedPair( + InterestCandidate scene, + LiveEnsemble ensemble, + int fighters) + { + if (scene == null) + { + return false; + } + + string tip = scene.Label ?? ""; + bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + + ResolveCombatPairActors(scene, out Actor owner, out Actor partner); + Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit; + Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit; + if (focus == null || !focus.isAlive() || related == null || !related.isAlive()) + { + return false; + } + + bool pairEngaged = LiveEnsemble.IsCombatParticipant(focus) + || LiveEnsemble.IsCombatParticipant(related); + + // Already-framed collective multi may leave NamedPair. Duel tips still escalate when + // sticky camps were enrolled (AI often drops attack_target on pack extras). + if (HasStickyCollectiveMulti(scene)) + { + if (!duelLabeled) + { + return true; + } + + int stickyN = scene.CombatSideACount + scene.CombatSideBCount; + if (stickyN >= 3 && pairEngaged) + { + return true; + } + } + + if (fighters < 3 || ensemble == null) + { + return false; + } + + bool sidedMulti = LiveEnsemble.HasOpposingCollectiveSides(ensemble) + || ensemble.Scale >= EnsembleScale.Skirmish; + if (!sidedMulti) + { + return false; + } + + // Probe-only escalate needs a real pack on both sides. A single attack_target + // distractor (Skirmish 2v1) is partner-swap noise and must keep the Duel lock. + int sideA = ensemble.SideA != null ? ensemble.SideA.Count : 0; + int sideB = ensemble.SideB != null ? ensemble.SideB.Count : 0; + if (sideA < 2 || sideB < 2) + { + return false; + } + + // Natural growth: pair still owns a living scrap inside a sided multi. + // Attack-target gaps on one partner must not block Duel → Mass/Battle. + return pairEngaged; + } + + /// + /// First partner died while the owner is still fighting: reframe without naming the + /// next attack_target (spectacle 1vN / scrap mop). Prefer collective tips when present. + /// + private static bool TryHoldOwnerAfterPartnerDeath( + InterestCandidate scene, + Actor ownedFocus, + LiveEnsemble ensemble, + ref Actor best, + ref Actor foe, + ref int fighters, + ref string label) + { + if (scene == null || scene.PairPartnerId == 0) + { + return false; + } + + // Alive-only check: corpses / destroyed refs must not keep a NamedPair tip. + if (EventFeedUtil.FindAliveById(scene.PairPartnerId) != null) + { + return false; + } + + Actor owner = ownedFocus != null && ownedFocus.isAlive() + ? ownedFocus + : EventFeedUtil.FindAliveById(scene.PairOwnerId); + if (owner == null || !owner.isAlive()) + { + return false; + } + + // Partner lock points at a dead unit. Collective Mass/Battle/Skirmish tips already + // own the scrap - never collapse them into thin "is fighting". + string prior = scene.Label ?? ""; + bool alreadyCollective = prior.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) + || prior.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) + || prior.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase); + if (alreadyCollective + || HasStickyCollectiveMulti(scene) + || (ensemble != null + && (ensemble.ParticipantCount >= 3 + || LiveEnsemble.HasOpposingCollectiveSides(ensemble) + || ensemble.Scale >= EnsembleScale.Skirmish))) + { + return false; + } + + // Thin NamedPair mop-up: never name a revolving Duel partner while the first + // partner lock still points at a dead unit. + best = owner; + foe = null; + fighters = Mathf.Max(1, fighters); + label = EventReason.Fight(owner, null); + scene.StampTheaterLead(owner); + return !string.IsNullOrEmpty(label); + } + + /// + /// When the tip is (or collapses to) a NamedPair Duel, keep the locked living partner + /// instead of adopting the owner's latest attack_target. + /// + private static bool TryRestoreLockedDuelPartner( + InterestCandidate scene, + Actor ownedFocus, + Actor ownedRelated, + ref Actor best, + ref Actor foe, + ref string label) + { + if (scene == null) + { + return false; + } + + Actor partner = ownedRelated; + if (partner == null || !partner.isAlive()) + { + if (scene.PairPartnerId != 0) + { + partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); + } + } + + if (partner == null || !partner.isAlive()) + { + return false; + } + + bool duelTip = !string.IsNullOrEmpty(label) + && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + bool thinFight = !string.IsNullOrEmpty(label) + && label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0; + if (!duelTip && !thinFight) + { + return false; + } + + if (foe == partner && (ownedFocus == null || best == ownedFocus || best == partner)) + { + return false; + } + + Actor focus = ownedFocus != null && ownedFocus.isAlive() + ? ownedFocus + : (best != null && best.isAlive() ? best : scene.FollowUnit); + if (focus == null || !focus.isAlive() || focus == partner) + { + return false; + } + + best = focus; + foe = partner; + int holdFighters = 2; + ApplyNamedPairHold(scene, focus, partner, ref best, ref foe, ref holdFighters, ref label); + return true; + } + + private static void ApplyNamedPairHold( + InterestCandidate scene, + Actor ownedFocus, + Actor ownedRelated, + ref Actor best, + ref Actor foe, + ref int fighters, + ref string label) + { + if (scene == null || ownedFocus == null || !ownedFocus.isAlive()) + { + return; + } + + // Drop thin ambient species/kingdom sticky that leaked in during cluster Refresh. + // Keep 3+ enrolled camps - those are intentional escalate seeds (wire / pack growth). + int enrolled = scene.CombatSideACount + scene.CombatSideBCount; + if (HasStickyCombatSides(scene) + && enrolled < 3 + && (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies + || scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom)) + { + scene.ClearCombatSticky(); + } + + best = ownedFocus; + foe = ResolvePairPartner(ownedFocus, ownedRelated, foe); + fighters = Mathf.Max(2, Math.Min(fighters, 2)); + scene.ParticipantCount = Mathf.Max(2, scene.ParticipantCount); + if (string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + && scene.CombatPeakParticipants <= 2) + { + scene.AssetId = "live_combat"; + } + + var pairHold = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = EnsembleScale.Pair, + Frame = EnsembleFrame.NamedPair, + ParticipantCount = 2, + Focus = best, + Related = foe + }; + label = EventReason.Combat(pairHold); + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(scene.Label)) + { + label = scene.Label; + } + } + + private static Actor ResolvePairPartner(Actor focus, Actor ownedRelated, Actor rebuiltFoe) + { + if (ownedRelated != null && ownedRelated.isAlive() && ownedRelated != focus) + { + return ownedRelated; + } + + Actor attack = ResolveAttackFoe(focus); + if (attack != null && attack != focus) + { + return attack; + } + + if (rebuiltFoe != null && rebuiltFoe.isAlive() && rebuiltFoe != focus) + { + return rebuiltFoe; + } + + return null; + } + + /// + /// Hold Duel ownership whenever Follow+Related still form the live pair. + /// Ambient sticky camps do not unlock a living NamedPair. + /// + private static bool ShouldHoldDuelOwnership( + InterestCandidate scene, + Actor best, + Actor foe, + int fighters, + Actor ownedFocus = null, + Actor ownedRelated = null) + { + if (scene == null) + { + return false; + } + + Actor curFocus = ownedFocus != null && ownedFocus.isAlive() + ? ownedFocus + : scene.FollowUnit; + Actor curRelated = ownedRelated != null && ownedRelated.isAlive() + ? ownedRelated + : scene.RelatedUnit; + if (curFocus == null || !curFocus.isAlive() + || curRelated == null || !curRelated.isAlive()) + { + return false; + } + + bool duelLabeled = !string.IsNullOrEmpty(scene.Label) + && scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + + // Ambient sticky camps must not unlock a living Duel tip (A↔B flip). + // Real multi leave is owned by ShouldEscalateNamedPair / theater lead, not this hold. + if (HasStickyCollectiveMulti(scene) && !duelLabeled) + { + return false; + } + + bool smallCluster = fighters <= 2; + if (!duelLabeled && !smallCluster && !scene.ForceActive) + { + return false; + } + + // Rebuilt focus/foe is the same two actors (any order), or only one of them + // remains visible in the cluster (foe briefly missing). + return IsSameCombatPair(curFocus, curRelated, best, foe); + } + + /// + /// True when / are the same two actors as the + /// current pair (possibly swapped). Used to lock Duel tip ownership. + /// + private static bool IsSameCombatPair(Actor curFocus, Actor curRelated, Actor best, Actor foe) + { + if (curFocus == null || !curFocus.isAlive() || best == null || !best.isAlive()) + { + return false; + } + + if (best == curFocus) + { + return foe == null + || foe == curRelated + || foe == curFocus + || (curRelated == null && ResolveAttackFoe(curFocus) == foe); + } + + if (best == curRelated) + { + return foe == null || foe == curFocus || foe == curRelated; + } + + Actor liveFoe = ResolveAttackFoe(curFocus) ?? curRelated; + return liveFoe != null && (best == liveFoe) && (foe == null || foe == curFocus); + } + + private static void ClearStaleCombatLabel(float now) + { + if (_current == null || string.IsNullOrEmpty(_current.Label)) + { + return; + } + + string label = _current.Label; + if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0 + && label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0) + { + return; + } + + _current.Label = ""; + _current.ClearCombatSticky(); + _current.ClearTheaterLead(); + _lastCombatFocusAt = now; + CameraDirector.Watch(_current.ToInterestEvent()); + } + + /// + /// Build a sticky combat tip ensemble with the theater-lead side listed first so + /// Mass/Battle wording does not flip A↔B across maintain ticks. + /// + private static void BuildStickyCombatTipEnsemble( + InterestCandidate scene, + Actor focus, + Actor related, + out LiveEnsemble stickyEns) + { + LiveSceneStickyState sticky = scene?.Sticky; + if (sticky != null) + { + StickyScoreboard.GetPresentationCounts(sticky, out int countA, out int countB); + bool mopUp = sticky.MopUpActive || countA <= 0 || countB <= 0; + int liveN = Math.Max(0, countA) + Math.Max(0, countB); + int scaleN = mopUp ? Math.Max(1, liveN) : Math.Max(3, scene.CombatPeakParticipants); + stickyEns = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = LiveEnsemble.ScaleForCount(scaleN), + Focus = focus, + Related = related, + Frame = scene.CombatSideFrame, + ParticipantCount = liveN + }; + + var sideA = new EnsembleSide + { + Key = scene.CombatSideAKey, + Display = scene.CombatSideADisplay, + KingdomDisplay = scene.CombatSideAKingdom, + Count = countA, + Best = focus + }; + var sideB = new EnsembleSide + { + Key = scene.CombatSideBKey, + Display = scene.CombatSideBDisplay, + KingdomDisplay = scene.CombatSideBKingdom, + Count = countB, + Best = related + }; + + bool focusOnB = false; + if (focus != null) + { + long id = EventFeedUtil.SafeId(focus); + for (int i = 0; i < sticky.SideBIds.Count; i++) + { + if (sticky.SideBIds[i] == id) + { + focusOnB = true; + break; + } + } + } + + if (focusOnB) + { + stickyEns.SideA = sideB; + stickyEns.SideA.Best = focus; + stickyEns.SideB = sideA; + stickyEns.SideB.Best = related; + } + else + { + stickyEns.SideA = sideA; + stickyEns.SideB = sideB; + } + + return; + } + + stickyEns = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants)), + Focus = focus, + Related = related, + Frame = scene.CombatSideFrame, + ParticipantCount = scene.CombatSideACount + scene.CombatSideBCount + }; + + var sideAFallback = new EnsembleSide + { + Key = scene.CombatSideAKey, + Display = scene.CombatSideADisplay, + KingdomDisplay = scene.CombatSideAKingdom, + Count = scene.CombatSideACount, + Best = focus + }; + var sideBFallback = new EnsembleSide + { + Key = scene.CombatSideBKey, + Display = scene.CombatSideBDisplay, + KingdomDisplay = scene.CombatSideBKingdom, + Count = scene.CombatSideBCount, + Best = related + }; + + stickyEns.SideA = sideAFallback; + stickyEns.SideB = sideBFallback; + } + + /// Harness: force one combat focus maintenance pass. + public static void HarnessMaintainCombatFocus() + { + MaintainCombatFocus(Time.unscaledTime, force: true); + } + + /// Harness: force one war-front maintenance pass. + public static void HarnessMaintainWarFront() + { + MaintainWarFront(Time.unscaledTime, force: true); + } + + /// Harness: force one plot-cell maintenance pass. + public static void HarnessMaintainPlotCell() + { + MaintainPlotCell(Time.unscaledTime, force: true); + } + + /// Harness: force one family-pack maintenance pass. + public static void HarnessMaintainFamilyPack() + { + MaintainFamilyPack(Time.unscaledTime, force: true); + } + + /// Harness: force one status-outbreak maintenance pass. + public static void HarnessMaintainStatusOutbreak() + { + MaintainStatusOutbreak(Time.unscaledTime, force: true); + } +} diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs new file mode 100644 index 0000000..568835b --- /dev/null +++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs @@ -0,0 +1,659 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +public static partial class InterestDirector +{ + private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch) + { + if (candidate == null) + { + return false; + } + + // Same FixedDwell beat already watched on this subject - never cut back in. + if (InterestVariety.IsBeatCooled(candidate)) + { + InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key); + return false; + } + + if (_current == null) + { + return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); + } + + float now = Time.unscaledTime; + + // Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap. + if (IsCombatPartnerSwapNoise(_current, candidate)) + { + InterestDropLog.Record( + "combat_partner_hold", + $"cur={_current.Key} next={candidate.Key}"); + return false; + } + + // Crisis signal tip (war / disaster / outbreak) owns the camera against Mass/Battle scraps. + // Must run before crisis cut-in: a leftover war crisis can MatchCrisis(Mass) while a + // small WarFront tip does not. Drop reason keeps war_theater_hold alias for harness. + if (StoryPlanner.IsCrisisCameraSignal(_current) + && candidate.Completion == InterestCompletionKind.CombatActive) + { + string holdReason = _current.Completion == InterestCompletionKind.WarFront + ? "war_theater_hold" + : "crisis_theater_hold"; + InterestDropLog.Record( + holdReason, + $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); + return false; + } + + // Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars + // against aftermath / ambient peers that outscore the closer on raw EventStrength. + if (StoryPlanner.CrisisActive + && StoryPlanner.MatchesCrisis(candidate) + && !StoryPlanner.MatchesCrisis(_current) + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace)) + { + return true; + } + + // Live fight: hold until cold (see who wins), unless something extremely urgent cuts in. + // Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom + // theater may reclaim the chapter (Mass → War) without waiting for combat cold. + if (CombatFightIsHot(_current, now) + && !IsSameStoryArc(_current, candidate) + && candidate.Completion != InterestCompletionKind.CombatActive) + { + bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront + && StickyScoreboard.SharesKingdomTheater(_current, candidate); + if (warTheaterReclaim) + { + return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); + } + + if (IsCombatUrgentPeer(_current, candidate)) + { + InterestDropLog.Record( + "combat_urgent_cut", + $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); + return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); + } + + InterestDropLog.Record( + "combat_hold", + $"cur={_current.Key} next={candidate.Key}"); + return false; + } + + bool inGrace = InQuietGrace; + if (inGrace) + { + // Story aftermath / same-arc continuation may cut freely during quiet grace. + if (IsSameStoryArc(_current, candidate) + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true)) + { + return true; + } + + // Quiet grace after a sticky hold: only margin-worthy events may cut in. + // Prevents family-join / love status from stealing a fight between swings. + if (_current != null + && (_current.Completion == InterestCompletionKind.CombatActive + || _current.Completion == InterestCompletionKind.StatusPhase + || _current.Completion == InterestCompletionKind.HappinessGrief + || IsStickyStoryScene(_current))) + { + float graceCur = _current.TotalScore; + float graceNext = candidate.TotalScore; + float onCurrentGrace = now - _currentStartedAt; + ScoringWeights gw = InterestScoringConfig.W; + float graceMargin = IsSoftStickyCluster(_current) + ? gw.cutInMargin + : Mathf.Max( + gw.cutInMargin, + gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f); + if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate)) + { + float valveMargin = gw.stickyVarietyValveMargin > 0f + ? gw.stickyVarietyValveMargin + : 20f; + graceMargin = Mathf.Min(graceMargin, valveMargin); + if (graceNext >= graceCur - gw.rotateSlack + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true)) + { + return true; + } + } + + return graceNext >= graceCur + graceMargin + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); + } + + return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); + } + + if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false)) + { + return false; + } + + bool protectedScene = SessionProtected(now, onCurrent); + ScoringWeights w = InterestScoringConfig.W; + float curScore = _current.TotalScore; + float nextScore = candidate.TotalScore; + bool nextFill = InterestScoring.IsFillScore(nextScore); + bool curAmbient = IsAmbientShot(_current); + bool curFill = curAmbient || InterestScoring.IsFillScore(curScore); + bool sticky = IsStickyStoryScene(_current); + float cutMargin = sticky + ? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f) + : w.cutInMargin; + bool varietyValve = VarietyValveOpen(_current, onCurrent) + && IsVarietyValveCandidate(candidate); + if (varietyValve) + { + float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f; + cutMargin = Mathf.Min(cutMargin, valveMargin); + } + + // Ambient fill: any real event may take the camera immediately (no min dwell). + if (curAmbient && !nextFill && !IsAmbientShot(candidate)) + { + return true; + } + + // Soft family pack: yield to discrete unit events after a short hold. + if (IsSoftStickyCluster(_current) + && !IsSoftStickyCluster(candidate) + && !nextFill + && !IsAmbientShot(candidate) + && onCurrent >= SoftClusterYieldSeconds + && nextScore >= curScore - w.rotateSlack) + { + return true; + } + + // Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut. + // Score-margin alone can never clear Mass (~150) with lover (~78); use class gate. + if (varietyValve && !nextFill && !IsAmbientShot(candidate)) + { + bool hotEnough = InterestScoring.IsHotScore(nextScore) + || nextScore >= curScore - w.rotateSlack; + bool storyEnough = nextScore >= w.noticeScoreMin + || candidate.EventStrength >= w.noticeScoreMin; + if (hotEnough || storyEnough) + { + InterestDropLog.Record( + "variety_valve", + $"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}"); + return true; + } + } + + // Same-arc refresh (combat/hatch/grief keys) may replace without full margin. + if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack) + { + return true; + } + + // StoryPlanner hard-arc / crisis hold against unrelated peers. + float storyHold = Mathf.Max( + StoryPlanner.ArcHoldMargin(_current, candidate), + StoryPlanner.CrisisHoldMargin(_current, candidate)); + if (storyHold > 0f) + { + cutMargin = Mathf.Max(cutMargin, storyHold); + } + + // Instant score-margin cut - no settle grace, no MinDwell block. + // Soft clusters use the normal cut-in margin (not stickyCutInMargin). + float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin; + if (nextScore >= curScore + effectiveMargin) + { + return true; + } + + if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin) + { + InterestDropLog.Record( + "below_margin", + $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}" + + (varietyValve ? " valve" : "") + + (storyHold > 0f ? " story" : "")); + } + + // Fill never cuts a protected non-fill session without margin (already failed above). + if (nextFill && !curFill && protectedScene) + { + return false; + } + + float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds; + if (nextFill && curFill && onCurrent < fillRotate) + { + return false; + } + + // Protected / sticky EventLed hold: only margin or same-arc (handled above). + if ((protectedScene || sticky) && !curAmbient) + { + return false; + } + + // Peer rotate / stronger score after MinDwell when unprotected or on fill. + if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown) + { + return true; + } + + return onCurrent >= MinDwellFor(_current) + && sinceSwitch >= _switchCooldown + && nextScore >= curScore - w.rotateSlack; + } + + /// Soft multi-actor holds that must yield to discrete unit events. + private const float SoftClusterYieldSeconds = 4f; + + private static bool IsSoftStickyCluster(InterestCandidate c) => + c != null && c.Completion == InterestCompletionKind.FamilyPack; + + /// + /// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world + /// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers). + /// + private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next) + { + if (current == null || next == null) + { + return false; + } + + if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next)) + { + return false; + } + + ScoringWeights w = InterestScoringConfig.W; + float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f; + if (next.TotalScore >= current.TotalScore + stickyMargin) + { + return true; + } + + // King / leadership deaths sit under the epic floor (king_killed=88) but must cut + // live scraps - soak missed a king slaying while parked on a Duel thrash. + if (IsLeadershipDeathPeer(next)) + { + return true; + } + + float epicMin = w.combatUrgentEventStrengthMin > 0f + ? w.combatUrgentEventStrengthMin + : 95f; + float urgentMargin = w.combatUrgentCutInMargin > 0f + ? w.combatUrgentCutInMargin + : 20f; + return next.EventStrength >= epicMin + && next.TotalScore >= current.TotalScore + urgentMargin; + } + + /// + /// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers. + /// + private static bool IsLeadershipDeathPeer(InterestCandidate c) + { + if (c == null) + { + return false; + } + + string id = (c.AssetId ?? "").ToLowerInvariant(); + if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0 + || id.IndexOf("king_dead", StringComparison.Ordinal) >= 0 + || id.IndexOf("king_died", StringComparison.Ordinal) >= 0 + || id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0 + || id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0) + { + return true; + } + + if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return id.IndexOf("kill", StringComparison.Ordinal) >= 0 + || id.IndexOf("dead", StringComparison.Ordinal) >= 0 + || id.IndexOf("died", StringComparison.Ordinal) >= 0 + || id.IndexOf("slain", StringComparison.Ordinal) >= 0; + } + + /// + /// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in. + /// Live CombatActive scraps never open the valve - hold until the fight goes cold. + /// + private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) => + !_varietyValveUsed + && VarietyValveTimeReady(scene, onCurrent) + && !CombatFightIsHot(scene, Time.unscaledTime); + + private static bool IsStickyStoryScene(InterestCandidate c) + { + if (c == null) + { + return false; + } + + // FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster). + if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive + || c.Completion == InterestCompletionKind.StatusPhase + || c.Completion == InterestCompletionKind.StatusOutbreak + || c.Completion == InterestCompletionKind.HappinessGrief) + { + return true; + } + + // Hatch FixedDwell is a story beat - hold against weak peers. + if (!string.IsNullOrEmpty(c.HappinessEffectId) + && (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (!string.IsNullOrEmpty(c.Key) + && (c.Key.StartsWith("combat:", StringComparison.Ordinal) + || c.Key.StartsWith("hatch:", StringComparison.Ordinal))) + { + return true; + } + + return false; + } + + private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next) + { + if (current == null || next == null) + { + return false; + } + + if (!string.IsNullOrEmpty(current.Key) + && !string.IsNullOrEmpty(next.Key) + && string.Equals(current.Key, next.Key, StringComparison.Ordinal)) + { + return true; + } + + // Hatch: same subject beat may refresh without full margin. + if (!string.IsNullOrEmpty(current.Key) + && !string.IsNullOrEmpty(next.Key) + && SameKeyPrefix(current.Key, next.Key, "hatch:")) + { + return true; + } + + // Combat: only the same unordered pair (or escalate of that scrap) is same-arc. + // Different combat:pair keys used to soft-cut and thrash Duel partners. + if (current.Completion == InterestCompletionKind.CombatActive + && next.Completion == InterestCompletionKind.CombatActive + && IsSameCombatPairArc(current, next)) + { + return true; + } + + // Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut). + if (current.Completion == InterestCompletionKind.CombatActive + && next.Completion == InterestCompletionKind.WarFront + && StickyScoreboard.SharesKingdomTheater(current, next)) + { + return true; + } + + if (current.Completion == InterestCompletionKind.WarFront + && next.Completion == InterestCompletionKind.WarFront + && StickyScoreboard.SharesKingdomTheater(current, next)) + { + return true; + } + + if (current.Completion == InterestCompletionKind.HappinessGrief + && next.Completion == InterestCompletionKind.HappinessGrief + && current.SubjectId != 0 + && current.SubjectId == next.SubjectId) + { + return true; + } + + // StoryPlanner aftermath / epilogue of the active climax. + if (StoryPlanner.IsContinuationOf(current, next)) + { + return true; + } + + return false; + } + + /// + /// True when next is the same 1v1 pair or a multi escalate of the current scrap. + /// + private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next) + { + if (current == null || next == null) + { + return false; + } + + if (!string.IsNullOrEmpty(current.Key) + && !string.IsNullOrEmpty(next.Key) + && string.Equals(current.Key, next.Key, StringComparison.Ordinal)) + { + return true; + } + + CollectCombatActorIds(current, out long curA, out long curB); + CollectCombatActorIds(next, out long nextA, out long nextB); + bool samePair = curA != 0 + && curB != 0 + && nextA != 0 + && nextB != 0 + && ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA)); + if (samePair) + { + return true; + } + + // Duel → Mass/Battle escalate: shared fighter + larger theater. + bool nextMulti = next.ParticipantCount >= 3 + || string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + || HasStickyCollectiveMulti(next); + if (!nextMulti) + { + return false; + } + + return SharesCombatFighterId(current, nextA) + || SharesCombatFighterId(current, nextB) + || SharesCombatFighterId(next, curA) + || SharesCombatFighterId(next, curB); + } + + /// + /// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold. + /// + private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next) + { + if (current == null + || next == null + || current.Completion != InterestCompletionKind.CombatActive + || next.Completion != InterestCompletionKind.CombatActive) + { + return false; + } + + // Multi escalate / different theater is not partner-swap noise. + bool nextMulti = next.ParticipantCount >= 3 + || HasStickyCollectiveMulti(next) + || (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + && next.ParticipantCount > 2); + if (nextMulti && !IsThinPairCombat(next)) + { + return false; + } + + CollectCombatActorIds(current, out long curA, out long curB); + CollectCombatActorIds(next, out long nextA, out long nextB); + if (curA == 0 && curB == 0 + && current.TheaterLeadId == 0 + && current.PairOwnerId == 0 + && current.SubjectId == 0) + { + return false; + } + + // Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise. + if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current)) + { + if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB)) + { + return true; + } + + return false; + } + + if (!IsThinPairCombat(next)) + { + return false; + } + + // Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise. + // Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live. + bool sameUnorderedPair = curA != 0 + && curB != 0 + && nextA != 0 + && nextB != 0 + && ((curA == nextA && curB == nextB) + || (curA == nextB && curB == nextA)); + if (sameUnorderedPair) + { + long curSub = current.SubjectId != 0 + ? current.SubjectId + : (current.PairOwnerId != 0 + ? current.PairOwnerId + : EventFeedUtil.SafeId(current.FollowUnit)); + long nextSub = next.SubjectId != 0 + ? next.SubjectId + : EventFeedUtil.SafeId(next.FollowUnit); + if (curSub != 0 && nextSub != 0 && curSub != nextSub) + { + return true; + } + + return false; + } + + // Shared fighter + different partner = attack_target thrash. + return SharesCombatOwnerOrLead(current, nextA, nextB) + || SharesCombatFighterId(current, nextA) + || SharesCombatFighterId(current, nextB); + } + + private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB) + { + if (current == null) + { + return false; + } + + long lead = current.TheaterLeadId != 0 + ? current.TheaterLeadId + : (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId); + if (lead == 0) + { + lead = EventFeedUtil.SafeId(current.FollowUnit); + } + + return lead != 0 && (lead == nextA || lead == nextB); + } + + private static bool IsThinPairCombat(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (c.HasCombatPairLock || c.ParticipantCount <= 2) + { + return true; + } + + string tip = c.Label ?? ""; + if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + string key = c.Key ?? ""; + return key.StartsWith("combat:pair:", StringComparison.Ordinal) + || (key.StartsWith("combat:", StringComparison.Ordinal) + && key.IndexOf(":pair:", StringComparison.Ordinal) < 0 + && !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)); + } + + private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b) + { + a = 0; + b = 0; + if (c == null) + { + return; + } + + a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId; + b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId; + if (a == 0) + { + a = EventFeedUtil.SafeId(c.FollowUnit); + } + + if (b == 0) + { + b = EventFeedUtil.SafeId(c.RelatedUnit); + } + } + + private static bool SharesCombatFighterId(InterestCandidate c, long id) + { + if (c == null || id == 0) + { + return false; + } + + CollectCombatActorIds(c, out long a, out long b); + return id == a || id == b; + } + + private static bool SameKeyPrefix(string a, string b, string prefix) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix)) + { + return false; + } + + if (!a.StartsWith(prefix, StringComparison.Ordinal) + || !b.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(a, b, StringComparison.Ordinal); + } +} diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index b186270..d00b8a7 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -10,7 +10,7 @@ namespace IdleSpectator; /// preemption with typed combat/vignette rules, soft 70/30 variety, completion via /// . /// -public static class InterestDirector +public static partial class InterestDirector { public const float MinDwellSeconds = 15f; public const float CuriosityDwellSeconds = 6f; @@ -130,15 +130,8 @@ public static class InterestDirector return null; } - // Dossier subject asking for the reason must match camera focus when focused. - if (MoveCamera.hasFocusUnit() - && MoveCamera._focus_unit != null - && MoveCamera._focus_unit != unit - && EventFeedUtil.SafeId(MoveCamera._focus_unit) != EventFeedUtil.SafeId(unit)) - { - return null; - } - + // Tip principals own the reason even while the camera mid-pans on a Love/life + // handoff (FocusUnit can briefly disagree with the dossier subject for a frame). return UnitIsReasonPrincipal(unit, _current) ? _current : null; } @@ -461,8 +454,15 @@ public static class InterestDirector return SelectNext(Time.unscaledTime); } + private static InterestCandidate _browseFrozenCurrent; + private static InterestCandidate _browseFrozenInterrupted; + private static float _browseFrozenStartedAt = -999f; + private static float _browseFrozenLastSwitchAt = -999f; + private static float _browseFrozenLastInterestingAt = -999f; + public static void OnSpectatorEnabled() { + ClearBrowseFreeze(); InterestRegistry.Clear(); InterestVariety.Clear(); InterestScoring.ClearCache(); @@ -487,6 +487,7 @@ public static class InterestDirector public static void OnSpectatorDisabled() { + ClearBrowseFreeze(); _current = null; _interrupted = null; InterestRegistry.Clear(); @@ -495,11 +496,63 @@ public static class InterestDirector StoryPlanner.Clear(); } + /// + /// Lore/browse freeze: keep StoryPlanner, registry, and session candidates; stop directing. + /// + public static void OnSpectatorBrowsePaused() + { + _browseFrozenCurrent = _current; + _browseFrozenInterrupted = _interrupted; + _browseFrozenStartedAt = _currentStartedAt; + _browseFrozenLastSwitchAt = _lastSwitchAt; + _browseFrozenLastInterestingAt = _lastInterestingAt; + _current = null; + _interrupted = null; + // Do not Clear StoryPlanner / InterestRegistry / variety - resume continues the story. + } + + /// + /// Resume after without wiping arcs/crisis/ledger. + /// + public static void OnSpectatorBrowseResumed() + { + _current = _browseFrozenCurrent; + _interrupted = _browseFrozenInterrupted; + _currentStartedAt = _browseFrozenStartedAt; + _lastSwitchAt = _browseFrozenLastSwitchAt; + _lastInterestingAt = _browseFrozenLastInterestingAt; + ClearBrowseFreeze(); + float now = Time.unscaledTime; + _enabledAt = now; + _inactiveSince = -999f; + _lastAmbientAt = -999f; + _lastFeedsAt = -999f; + // Re-bind camera to the frozen story beat when still watchable. + if (_current != null) + { + CameraDirector.Watch(_current.ToInterestEvent()); + _lastInterestingAt = now; + } + else if (!AgentHarness.Busy) + { + TryCharacterFill(force: true); + } + } + + private static void ClearBrowseFreeze() + { + _browseFrozenCurrent = null; + _browseFrozenInterrupted = null; + _browseFrozenStartedAt = -999f; + _browseFrozenLastSwitchAt = -999f; + _browseFrozenLastInterestingAt = -999f; + } + public static void PauseForBrowsing(string banner = "Paused (viewing history)") { if (SpectatorMode.Active) { - SpectatorMode.SetActive(false, quiet: true); + SpectatorMode.SetActive(false, quiet: true, browsePause: true); } WatchCaption.PinWhilePaused(); @@ -544,6 +597,7 @@ public static class InterestDirector float sinceSwitch = now - _lastSwitchAt; StoryPlanner.Tick(now); + LifeSagaRoster.Tick(now); UpdateSessionLiveness(now, onCurrent); MaintainCombatFocus(now, force: false); MaintainWarFront(now, force: false); @@ -819,1086 +873,6 @@ public static class InterestDirector EndCurrent(Time.unscaledTime, reason: "follow_lost"); } - /// - /// Keep camera on the highest-scored combat participant and rewrite tip/counts - /// from a live so subject + scale always match focus. - /// Clears fight Labels once combat goes cold. - /// - private static void MaintainCombatFocus(float now, bool force) - { - if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) - { - return; - } - - bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now); - if (!combatHot) - { - ClearStaleCombatLabel(now); - return; - } - - float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold - ? CombatFocusThrottleLargeSeconds - : CombatFocusThrottleSeconds; - if (!force && now - _lastCombatFocusAt < throttle) - { - return; - } - - _lastCombatFocusAt = now; - ApplyCombatEnsembleToCurrent(forceWatch: false); - } - - /// - /// Refresh active combat scene from the world ensemble. Returns false if no focus. - /// - private static bool ApplyCombatEnsembleToCurrent(bool forceWatch) - { - if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) - { - return false; - } - - // Snapshot durable pair ownership before cluster rebuild can rewrite Follow+Related. - ResolveCombatPairActors(_current, out Actor ownedFocus, out Actor ownedRelated); - if (ownedFocus != null) - { - _current.StampCombatPair(ownedFocus, ownedRelated); - } - - // NamedPair / living Duel ownership holds the pair. Do not gate on AssetId==live_battle - // or peak alone - ambient nearby scraps polluted those flags and disabled pair hold so - // the tip thrashed onto strangers while CombatStillActive stayed hot forever. - bool pairOwned = ownedFocus != null - && ownedFocus.isAlive() - && ownedRelated != null - && ownedRelated.isAlive() - && IsNamedPairCombatOwnership(_current); - - // Fast path: living NamedPair skips sticky Refresh (ambient camps were flipping Duels). - // Probe the local scrap first so a real sided multi can escalate Duel → Skirmish/Battle/Mass. - // Skip when Follow was cleared (death / harness handoff) so Related can take ownership. - if (pairOwned - && _current.HasFollowUnit - && ownedFocus != null - && ownedFocus.isAlive() - && ownedRelated != null - && ownedRelated.isAlive()) - { - LiveEnsemble.TryBuildCombat( - ownedFocus.current_position, - 14f, - out LiveEnsemble escalateProbe, - priorParticipantIds: _current.ParticipantIds, - newcomerBonus: 8f); - int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2; - bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus); - bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated); - int stickyCampN = _current.CombatSideACount + _current.CombatSideBCount; - // Sticky camps enrolled (pack wire / live growth) outrank NamedPair hold even when - // the live probe still looks like a thin Duel this tick. - bool stickyPackEscalate = HasStickyCollectiveMulti(_current) - && stickyCampN >= 3 - && (focusFighting || relatedFighting); - // Both left the scrap (sleep / chores): keep NamedPair framing and do NOT fall - // through to ambient fighter pick. Absorbing nearby bears into Duel - Honya vs Waf - // refreshed combat hotness forever after the real pair went cold (Neen soak). - if (!focusFighting && !relatedFighting) - { - string idleLabel = ""; - Actor idleFocus = ownedFocus; - Actor idleFoe = ownedRelated; - int idleFighters = 2; - ApplyNamedPairHold( - _current, ownedFocus, ownedRelated, ref idleFocus, ref idleFoe, ref idleFighters, ref idleLabel); - string priorIdle = _current.Label ?? ""; - if (string.IsNullOrEmpty(idleLabel)) - { - idleLabel = priorIdle; - } - - bool idleFollowChanged = _current.FollowUnit != idleFocus; - bool idleLabelChanged = !string.Equals(priorIdle, idleLabel ?? "", StringComparison.Ordinal); - _current.FollowUnit = idleFocus; - _current.SubjectId = EventFeedUtil.SafeId(idleFocus); - _current.RelatedUnit = idleFoe; - _current.RelatedId = idleFoe != null ? EventFeedUtil.SafeId(idleFoe) : 0; - _current.Label = idleLabel; - try - { - _current.Position = idleFocus.current_position; - } - catch - { - // keep - } - - bool idleNeedWatch = forceWatch - || idleFollowChanged - || idleLabelChanged - || !HasLivingCameraFocus() - || MoveCamera._focus_unit != idleFocus; - if (idleNeedWatch) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - StoryPlanner.SyncActiveClimax(_current); - return true; - } - - if (!stickyPackEscalate - && !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) - { - string pairLabel = ""; - Actor holdFocus = ownedFocus; - Actor holdFoe = ownedRelated; - int holdFighters = 2; - ApplyNamedPairHold( - _current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel); - string priorLabel = _current.Label ?? ""; - if (string.IsNullOrEmpty(pairLabel)) - { - pairLabel = priorLabel; - } - - bool holdFollowChanged = _current.FollowUnit != holdFocus; - bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal); - _current.FollowUnit = holdFocus; - _current.SubjectId = EventFeedUtil.SafeId(holdFocus); - _current.RelatedUnit = holdFoe; - _current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0; - _current.Label = pairLabel; - try - { - _current.Position = holdFocus.current_position; - } - catch - { - // keep - } - - bool holdNeedWatch = forceWatch - || holdFollowChanged - || holdLabelChanged - || !HasLivingCameraFocus() - || MoveCamera._focus_unit != holdFocus; - if (holdNeedWatch) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - StoryPlanner.SyncActiveClimax(_current); - return true; - } - - // Real multi around the pair: fall through to sticky rebuild / collective tip. - } - - Vector3 pos = _current.Position; - if (ownedFocus != null && ownedFocus.isAlive()) - { - pos = ownedFocus.current_position; - } - else if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) - { - pos = _current.FollowUnit.current_position; - } - else if (ownedRelated != null && ownedRelated.isAlive()) - { - pos = ownedRelated.current_position; - } - else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) - { - pos = _current.RelatedUnit.current_position; - } - - bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) - || _current.ParticipantCount > 2 - || HasStickyCombatSides(_current); - float radius = massHint ? 14f : 10f; - - // Death / clear-follow handoff: promote Related before cluster re-pick can steal focus. - if (!_current.HasFollowUnit - && _current.RelatedUnit != null - && _current.RelatedUnit.isAlive()) - { - Actor handoff = _current.RelatedUnit; - Actor handoffFoe = null; - try - { - if (handoff.has_attack_target - && handoff.attack_target != null - && handoff.attack_target.isAlive() - && handoff.attack_target.isActor()) - { - handoffFoe = handoff.attack_target.a; - if (handoffFoe != null && !handoffFoe.isAlive()) - { - handoffFoe = null; - } - } - } - catch - { - handoffFoe = null; - } - - LiveEnsemble.TryBuildCombat( - handoff.current_position, - radius, - out LiveEnsemble handoffEnsemble, - priorParticipantIds: _current.ParticipantIds, - newcomerBonus: 8f); - if (handoffEnsemble != null) - { - StickyScoreboard.StabilizeScale(_current.Sticky, handoffEnsemble, Time.unscaledTime); - ApplyEnsembleFields(_current, handoffEnsemble); - StickyScoreboard.Refresh( - _current, handoffEnsemble, handoff.current_position, radius); - } - - // Keep ownership on the handoff unit; only use ensemble framing when the melee is large. - string handoffLabel; - if (handoffEnsemble != null - && (handoffEnsemble.ParticipantCount > 2 - || StickyScoreboard.HasOpposingSides(_current) - || handoffEnsemble.Scale != EnsembleScale.Pair)) - { - handoffEnsemble.Focus = handoff; - if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff) - { - handoffEnsemble.Related = handoffFoe; - } - - handoffLabel = EventReason.Combat(handoffEnsemble); - } - else - { - // NamedPair handoff: keep Duel framing with the other pair id when possible. - if (handoffFoe == null || !handoffFoe.isAlive()) - { - long handoffId = EventFeedUtil.SafeId(handoff); - long otherId = _current.PairOwnerId != 0 && _current.PairOwnerId != handoffId - ? _current.PairOwnerId - : _current.PairPartnerId; - if (otherId != 0 && otherId != handoffId) - { - handoffFoe = LiveEnsemble.FindTrackedActor(otherId); - } - } - - var pairHandoff = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = EnsembleScale.Pair, - Frame = EnsembleFrame.NamedPair, - ParticipantCount = 2, - Focus = handoff, - Related = handoffFoe - }; - handoffLabel = EventReason.Combat(pairHandoff); - if (string.IsNullOrEmpty(handoffLabel)) - { - handoffLabel = EventReason.Fight(handoff, handoffFoe); - } - - _current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount); - } - - bool handoffFollowChanged = _current.FollowUnit != handoff; - bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal); - _current.FollowUnit = handoff; - _current.SubjectId = EventFeedUtil.SafeId(handoff); - _current.RelatedUnit = handoffFoe; - _current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0; - _current.StampCombatPair(handoff, handoffFoe); - _current.Label = handoffLabel; - try - { - _current.Position = handoff.current_position; - } - catch - { - // keep - } - - if (forceWatch - || handoffFollowChanged - || handoffLabelChanged - || !HasLivingCameraFocus() - || MoveCamera._focus_unit != handoff) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - StoryPlanner.SyncActiveClimax(_current); - return true; - } - - LiveEnsemble.TryBuildCombat( - pos, - radius, - out LiveEnsemble ensemble, - priorParticipantIds: _current.ParticipantIds, - newcomerBonus: 8f); - - Actor best; - Actor foe; - int fighters; - string label; - if (ensemble != null && ensemble.HasFocus) - { - StickyScoreboard.StabilizeScale(_current.Sticky, ensemble, Time.unscaledTime); - best = ensemble.Focus; - foe = ensemble.Related; - fighters = Mathf.Max(0, ensemble.ParticipantCount); - ApplyEnsembleFields(_current, ensemble); - StickyScoreboard.Refresh(_current, ensemble, pos, radius); - label = EventReason.Combat(ensemble); - } - else if (WorldActivityScanner.TryPickBestCombatFocus( - _current, out best, out foe, out fighters)) - { - var fallback = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = LiveEnsemble.ScaleForCount(fighters), - Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly, - ParticipantCount = fighters, - Focus = best, - Related = foe - }; - StickyScoreboard.StabilizeScale(_current.Sticky, fallback, Time.unscaledTime); - ApplyEnsembleFields(_current, fallback); - StickyScoreboard.Refresh(_current, fallback, pos, radius); - label = EventReason.Combat(fallback); - _current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount); - } - else if (StickyScoreboard.HasOpposingSides(_current)) - { - // No live fighters briefly - keep framed tip from sticky roster until combat goes cold. - if (!_current.HasFollowUnit) - { - StickyScoreboard.TryPromoteFollow(_current); - } - - var held = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), - Focus = _current.FollowUnit, - Related = _current.RelatedUnit, - ParticipantCount = 0 - }; - StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); - StickyScoreboard.Refresh(_current, held, pos, radius); - if (held.Focus == null || !held.Focus.isAlive()) - { - held.Focus = _current.RelatedUnit; - } - - if ((held.Focus == null || !held.Focus.isAlive()) && StickyScoreboard.TryPromoteFollow(_current)) - { - held.Focus = _current.FollowUnit; - held.Related = _current.RelatedUnit; - StickyScoreboard.Refresh(_current, held, pos, radius); - } - - if (held.Focus == null || !held.Focus.isAlive()) - { - return false; - } - - best = held.Focus; - foe = held.Related; - label = EventReason.Combat(held); - fighters = _current.CombatSideACount + _current.CombatSideBCount; - } - else - { - return false; - } - - // Prefer the ownership snapshot over any Follow/Related rewrite during Refresh. - Actor curFollow = ownedFocus != null && ownedFocus.isAlive() - ? ownedFocus - : _current.FollowUnit; - Actor curRelated = ownedRelated != null && ownedRelated.isAlive() - ? ownedRelated - : _current.RelatedUnit; - - // Named-pair lock: ambient nearby camps must not steal / flip a living Duel. - // Only a true multi escalate (or intentional sticky Battle) may leave the pair. - if (pairOwned - && curFollow != null - && curFollow.isAlive() - && !ShouldEscalateNamedPair(_current, ensemble, fighters)) - { - ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); - } - else - { - // Keep PairOwner/Partner across escalate. Sticky multi already disables NamedPair - // hold; wiping the lock made Mass collapse into Duel vs the latest attack_target. - - // Collective Mass/Battle: hold the theater lead (best across both sides). - // Attack-target gaps used to hop the camera across the mob every maintain tick. - if (TryApplyTheaterLeadHold(_current, ref best, ref foe, fighters)) - { - // Theater lead owns Follow/Related for this maintain. - } - else - { - // Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores). - bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); - bool bestFighting = LiveEnsemble.IsCombatParticipant(best); - if (!curFighting) - { - if (bestFighting) - { - foe = ResolveAttackFoe(best) ?? foe; - } - else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe)) - { - best = fighter; - foe = fighterFoe ?? foe; - if (ensemble != null) - { - label = EventReason.Combat(ensemble); - } - } - else if (curFollow != null && curFollow.isAlive()) - { - // Nobody fighting - keep ownership rather than jump to a random sleeper. - best = curFollow; - foe = ResolveAttackFoe(best) ?? curRelated ?? foe; - } - } - else if (!bestFighting) - { - best = curFollow; - foe = ResolveAttackFoe(best) ?? foe; - } - else if (best != curFollow - && curFollow != null - && curFollow.isAlive() - && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) - { - float curW = LiveEnsemble.FocusWeight( - curFollow, _current.ParticipantIds, newcomerBonus: 0f); - float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f); - if (newW < curW + CombatFocusSwitchMargin) - { - best = curFollow; - if (foe == null || foe == best) - { - foe = ResolveAttackFoe(best) ?? foe; - } - } - } - - // Preserve duel partner when rebuild briefly loses Related (attack_target gaps). - bool duelLabeled = !string.IsNullOrEmpty(_current.Label) - && _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); - if (foe == null - && curRelated != null - && curRelated.isAlive() - && curRelated != best - && (duelLabeled || _current.ForceActive || fighters <= 2)) - { - foe = curRelated; - } - - // Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks. - if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) - { - ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); - } - } - } - - // Prefer sticky collective tips over thin Fight / leftover Duel prose. - // Live probes often collapse to NamedPair when AI drops pack attack_targets. - int stickyFighters = _current.CombatSideACount + _current.CombatSideBCount; - bool stickyCollectiveTip = HasStickyCombatSides(_current) - && (HasStickyCollectiveMulti(_current) || stickyFighters >= 3); - if (HasStickyCombatSides(_current) - && (string.IsNullOrEmpty(label) - || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0 - || (stickyCollectiveTip - && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)))) - { - // Keep theater-lead side first so Mass tip order does not flip every reframe. - BuildStickyCombatTipEnsemble(_current, best, foe, out LiveEnsemble stickyEns); - label = EventReason.Combat(stickyEns); - } - - string previousLabel = _current.Label ?? ""; - if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel)) - { - label = previousLabel; - } - - // Never drop the duel partner while the tip is still a Duel - empty Related - // makes the next maintain flip ownership to a thin "X is fighting" tip. - if (foe == null - && curRelated != null - && curRelated.isAlive() - && curRelated != best - && !string.IsNullOrEmpty(label) - && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) - { - foe = curRelated; - } - - // Living pair lock owns NamedPair tip wording across attack_target thrash. - // Theater Mass/Battle may keep a fresh foe for framing; Duel must not hop partners. - if (TryRestoreLockedDuelPartner( - _current, curFollow, curRelated, ref best, ref foe, ref label)) - { - // Locked partner restored. - } - else if (TryHoldOwnerAfterPartnerDeath( - _current, curFollow, ensemble, ref best, ref foe, ref fighters, ref label)) - { - // Owner still fighting after first partner died - no revolving Duel names. - } - - bool followChanged = _current.FollowUnit != best; - bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); - bool framingOnly = labelChanged - && (EventReason.IsHeadcountOnlyChange(previousLabel, label ?? "") - || EventReason.IsCombatFramingOnlyChange(previousLabel, label ?? "")); - _current.FollowUnit = best; - _current.SubjectId = EventFeedUtil.SafeId(best); - _current.RelatedUnit = foe; - _current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0; - _current.Label = label; - try - { - _current.Position = best.current_position; - } - catch - { - // keep prior position - } - - // Headcount / side-order / Battle↔Mass reframes: keep Label live, skip Watch - // when the camera subject is already correct (avoids tip-log + caption churn). - bool needWatch = forceWatch - || followChanged - || (labelChanged && !framingOnly) - || !HasLivingCameraFocus() - || MoveCamera._focus_unit != best; - if (needWatch) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - // Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo). - StoryPlanner.SyncActiveClimax(_current); - return true; - } - - /// - /// Refresh sticky war tip (kingdom populations + follow) while WarFront completion is hot. - /// - private static bool MaintainWarFront(float now, bool force) - { - if (_current == null || _current.Completion != InterestCompletionKind.WarFront) - { - return false; - } - - if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) - { - return _current.HasFollowUnit; - } - - _lastCombatFocusAt = now; - return ApplyWarFrontMaintain(forceWatch: force); - } - - private static bool ApplyWarFrontMaintain(bool forceWatch) - { - if (_current == null || _current.Completion != InterestCompletionKind.WarFront) - { - return false; - } - - if (!_current.HasFollowUnit) - { - StickyScoreboard.TryPromoteFollow(_current); - } - - if (!_current.HasFollowUnit) - { - return false; - } - - Vector3 pos = _current.Position; - try - { - pos = _current.FollowUnit.current_position; - } - catch - { - // keep - } - - var held = new LiveEnsemble - { - Kind = EnsembleKind.WarFront, - Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), - Frame = EnsembleFrame.KingdomVsKingdom, - Focus = _current.FollowUnit, - Related = _current.RelatedUnit, - ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount - }; - StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); - StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.WarTheaterRadius); - _current.LastSeenAt = Time.unscaledTime; - if (held.Focus != null && held.Focus.isAlive()) - { - _current.FollowUnit = held.Focus; - _current.SubjectId = EventFeedUtil.SafeId(held.Focus); - } - - if (held.Related != null && held.Related.isAlive()) - { - _current.RelatedUnit = held.Related; - _current.RelatedId = EventFeedUtil.SafeId(held.Related); - } - - string label = EventReason.WarFront(held); - if (string.IsNullOrEmpty(label)) - { - label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); - } - - if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) - { - label = _current.Label; - } - - bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; - bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); - _current.Label = label ?? ""; - _current.AssetId = "live_war"; - try - { - if (_current.FollowUnit != null) - { - _current.Position = _current.FollowUnit.current_position; - } - } - catch - { - // keep - } - - bool needWatch = forceWatch - || followChanged - || labelChanged - || !HasLivingCameraFocus() - || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); - if (needWatch && _current.HasFollowUnit) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - return _current.HasFollowUnit; - } - - /// - /// Refresh sticky plot tip (plotter roster + target kingdom) while PlotActive is hot. - /// - private static bool MaintainPlotCell(float now, bool force) - { - if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) - { - return false; - } - - if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) - { - return _current.HasFollowUnit; - } - - _lastCombatFocusAt = now; - return ApplyPlotCellMaintain(forceWatch: force); - } - - private static bool ApplyPlotCellMaintain(bool forceWatch) - { - if (_current == null || _current.Completion != InterestCompletionKind.PlotActive) - { - return false; - } - - if (!_current.HasFollowUnit) - { - StickyScoreboard.TryPromoteFollow(_current); - } - - if (!_current.HasFollowUnit) - { - return false; - } - - Vector3 pos = _current.Position; - try - { - pos = _current.FollowUnit.current_position; - } - catch - { - // keep - } - - var held = new LiveEnsemble - { - Kind = EnsembleKind.PlotCell, - Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), - Frame = EnsembleFrame.KingdomVsKingdom, - Focus = _current.FollowUnit, - Related = _current.RelatedUnit, - ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount, - SideA = new EnsembleSide - { - Key = _current.CombatSideAKey, - Display = "Plotters", - Count = _current.CombatSideACount, - Best = _current.FollowUnit - }, - SideB = new EnsembleSide - { - Key = _current.CombatSideBKey, - Display = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), - KingdomDisplay = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey), - Count = _current.CombatSideBCount, - Best = _current.RelatedUnit - } - }; - StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); - StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.PlotTheaterRadius); - if (held.Focus != null && held.Focus.isAlive()) - { - _current.FollowUnit = held.Focus; - _current.SubjectId = EventFeedUtil.SafeId(held.Focus); - } - - if (held.Related != null && held.Related.isAlive()) - { - _current.RelatedUnit = held.Related; - _current.RelatedId = EventFeedUtil.SafeId(held.Related); - } - - // Plot stickies are groups only - never "Plot - Plotters (1)" / (0). - int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; - if (plotters < 2) - { - _current.Label = ""; - _current.AssetId = "live_plot"; - return _current.HasFollowUnit; - } - - string label = EventReason.PlotCell(held); - if (string.IsNullOrEmpty(label)) - { - label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); - } - - if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) - { - label = _current.Label; - } - - bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; - bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); - _current.Label = label ?? ""; - _current.AssetId = "live_plot"; - try - { - if (_current.FollowUnit != null) - { - _current.Position = _current.FollowUnit.current_position; - } - } - catch - { - // keep - } - - bool needWatch = forceWatch - || followChanged - || labelChanged - || !HasLivingCameraFocus() - || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); - if (needWatch && _current.HasFollowUnit) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - return _current.HasFollowUnit; - } - - /// - /// Refresh sticky family pack tip (roster + alpha promote) while FamilyPack is hot. - /// - private static bool MaintainFamilyPack(float now, bool force) - { - if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) - { - return false; - } - - if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) - { - return _current.HasFollowUnit; - } - - _lastCombatFocusAt = now; - return ApplyFamilyPackMaintain(forceWatch: force); - } - - private static bool ApplyFamilyPackMaintain(bool forceWatch) - { - if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack) - { - return false; - } - - if (!_current.HasFollowUnit) - { - StickyScoreboard.TryPromoteFollow(_current); - } - - if (!_current.HasFollowUnit) - { - return false; - } - - Vector3 pos = _current.Position; - try - { - pos = _current.FollowUnit.current_position; - } - catch - { - // keep - } - - var held = new LiveEnsemble - { - Kind = EnsembleKind.FamilyPack, - Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), - Frame = EnsembleFrame.SpeciesVsSpecies, - Focus = _current.FollowUnit, - Related = _current.RelatedUnit, - ParticipantCount = _current.CombatSideACount, - SideA = new EnsembleSide - { - Key = _current.CombatSideAKey, - Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) - ? LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(_current.FollowUnit)) - : _current.Sticky.SideADisplay, - Count = _current.CombatSideACount, - Best = _current.FollowUnit - } - }; - StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); - StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.FamilyTheaterRadius); - if (held.Focus != null && held.Focus.isAlive()) - { - _current.FollowUnit = held.Focus; - _current.SubjectId = EventFeedUtil.SafeId(held.Focus); - } - - if (held.Related != null && held.Related.isAlive()) - { - _current.RelatedUnit = held.Related; - _current.RelatedId = EventFeedUtil.SafeId(held.Related); - } - - string label = EventReason.FamilyPack(held); - if (string.IsNullOrEmpty(label)) - { - label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); - } - - if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) - { - label = _current.Label; - } - - bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; - bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); - _current.Label = label ?? ""; - _current.AssetId = "live_family"; - try - { - if (_current.FollowUnit != null) - { - _current.Position = _current.FollowUnit.current_position; - } - } - catch - { - // keep - } - - bool needWatch = forceWatch - || followChanged - || labelChanged - || !HasLivingCameraFocus() - || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); - if (needWatch && _current.HasFollowUnit) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - return _current.HasFollowUnit; - } - - /// - /// Refresh sticky status outbreak tip while StatusOutbreak completion is hot. - /// - private static bool MaintainStatusOutbreak(float now, bool force) - { - if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) - { - return false; - } - - if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) - { - return _current.HasFollowUnit; - } - - _lastCombatFocusAt = now; - return ApplyStatusOutbreakMaintain(forceWatch: force); - } - - private static bool ApplyStatusOutbreakMaintain(bool forceWatch) - { - if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak) - { - return false; - } - - if (!_current.HasFollowUnit) - { - StickyScoreboard.TryPromoteFollow(_current); - } - - if (!_current.HasFollowUnit) - { - return false; - } - - Vector3 pos = _current.Position; - try - { - pos = _current.FollowUnit.current_position; - } - catch - { - // keep - } - - string statusId = _current.StatusId ?? ""; - if (string.IsNullOrEmpty(statusId) - && LiveEnsemble.IsStatusOutbreakSideKey(_current.CombatSideAKey)) - { - statusId = _current.CombatSideAKey.Substring("status:".Length); - _current.StatusId = statusId; - } - - var held = new LiveEnsemble - { - Kind = EnsembleKind.StatusOutbreak, - Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)), - Frame = EnsembleFrame.SpeciesVsSpecies, - Focus = _current.FollowUnit, - Related = _current.RelatedUnit, - ParticipantCount = _current.CombatSideACount, - SideA = new EnsembleSide - { - Key = string.IsNullOrEmpty(_current.CombatSideAKey) - ? "status:" + statusId - : _current.CombatSideAKey, - Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay) - ? LiveEnsemble.StatusDisplayName(statusId) - : _current.Sticky.SideADisplay, - Count = _current.CombatSideACount, - Best = _current.FollowUnit - } - }; - StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime); - StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.StatusOutbreakRadius); - if (held.Focus != null && held.Focus.isAlive()) - { - _current.FollowUnit = held.Focus; - _current.SubjectId = EventFeedUtil.SafeId(held.Focus); - } - - if (held.Related != null && held.Related.isAlive()) - { - _current.RelatedUnit = held.Related; - _current.RelatedId = EventFeedUtil.SafeId(held.Related); - } - - // Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1). - int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0; - if (carriers < 2) - { - _current.Label = ""; - _current.AssetId = "live_outbreak"; - return _current.HasFollowUnit; - } - - string label = EventReason.StatusOutbreak(held); - if (string.IsNullOrEmpty(label)) - { - label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit); - } - - if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label)) - { - label = _current.Label; - } - - bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus; - bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); - _current.Label = label ?? ""; - _current.AssetId = "live_outbreak"; - try - { - if (_current.FollowUnit != null) - { - _current.Position = _current.FollowUnit.current_position; - } - } - catch - { - // keep - } - - bool needWatch = forceWatch - || followChanged - || labelChanged - || !HasLivingCameraFocus() - || (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit); - if (needWatch && _current.HasFollowUnit) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - return _current.HasFollowUnit; - } private static bool HasStickyCombatSides(InterestCandidate scene) { @@ -2126,684 +1100,6 @@ public static class InterestDirector return lead != null && lead.isAlive(); } - /// - /// Hold the collective theater lead through attack gaps, sticky rebuilds, and tip - /// reframes; retarget only on death, leaving the theater, or a clearly hotter fighter. - /// - private static bool TryApplyTheaterLeadHold( - InterestCandidate scene, - ref Actor best, - ref Actor foe, - int fighters) - { - if (scene == null) - { - return false; - } - - float now = Time.unscaledTime; - Actor held = null; - if (scene.TheaterLeadId != 0) - { - held = LiveEnsemble.FindTrackedActor(scene.TheaterLeadId); - } - - if (held == null || !held.isAlive()) - { - held = scene.FollowUnit != null && scene.FollowUnit.isAlive() - ? scene.FollowUnit - : null; - } - - bool hasDurableLead = held != null && held.isAlive() && scene.TheaterLeadId != 0; - if (!IsCollectiveCombatTheater(scene) && !hasDurableLead) - { - if (fighters < 3 || !HasStickyCombatSides(scene)) - { - return false; - } - - if (IsNamedPairCombatOwnership(scene)) - { - return false; - } - } - - if (IsNamedPairCombatOwnership(scene) && !hasDurableLead) - { - return false; - } - - if (held != null && LiveEnsemble.IsCombatParticipant(held)) - { - scene.TheaterLeadLastCombatAt = now; - } - - bool onRoster = held != null && StickyScoreboard.IsOnRoster(scene, held); - bool nearTheater = held != null && IsNearCombatTheater(scene, held); - bool fighting = held != null && LiveEnsemble.IsCombatParticipant(held); - bool inGrace = held != null - && held.isAlive() - && scene.TheaterLeadId != 0 - && EventFeedUtil.SafeId(held) == scene.TheaterLeadId - && now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds; - // Must be fighting or within post-fight grace. Roster/near only keep a real lead - // (chore bystanders parked on Follow must not inherit grace and stick the camera). - bool holdOk = held != null - && held.isAlive() - && (fighting || inGrace) - && (onRoster || nearTheater || fighting); - - if (!TryPickTheaterLead(scene, out Actor pick, out Actor pickFoe)) - { - if (!holdOk) - { - return false; - } - - best = held; - foe = ResolveAttackFoe(held) ?? OppositeSideBest(scene, held) ?? scene.RelatedUnit ?? foe; - scene.StampTheaterLead(held); - return true; - } - - if (holdOk) - { - float heldW = TheaterLeadScore( - scene, - held, - SideCountForActor(scene, held), - SideCountForOpponent(scene, held)); - float pickW = TheaterLeadScore( - scene, - pick, - SideCountForActor(scene, pick), - SideCountForOpponent(scene, pick)); - bool sameSide = SameStickySide(scene, held, pick); - float margin = sameSide - ? CombatTheaterLeadSameSideSwitchMargin - : CombatTheaterLeadSwitchMargin; - // Spectacle theater leads (evil mage, dragon, …) keep the camera harder. - if (WorldActivityScanner.IsSpectaclePublic(held)) - { - margin = Mathf.Max(margin, CombatTheaterLeadSameSideSwitchMargin); - } - - // Non-spectacle lead vs outnumbered spectacle challenger: steal easily so a - // crowned civilian cannot keep the camera through a 1vN mage fight. - bool pickSpectacleThin = !WorldActivityScanner.IsSpectaclePublic(held) - && WorldActivityScanner.IsSpectaclePublic(pick) - && SideCountForActor(scene, pick) > 0 - && SideCountForActor(scene, pick) - < SideCountForOpponent(scene, pick); - if (pickSpectacleThin) - { - margin = Mathf.Min(margin, 8f); - } - - bool steal = pick != held - && LiveEnsemble.IsCombatParticipant(pick) - && pickW >= heldW + margin; - if (!steal) - { - best = held; - foe = ResolveAttackFoe(held) - ?? OppositeSideBest(scene, held) - ?? pickFoe - ?? foe; - scene.StampTheaterLead(held); - return true; - } - } - - best = pick; - foe = ResolveAttackFoe(pick) ?? pickFoe ?? foe; - scene.StampTheaterLead(pick); - if (LiveEnsemble.IsCombatParticipant(pick)) - { - scene.TheaterLeadLastCombatAt = now; - } - - return true; - } - - private static bool IsNearCombatTheater(InterestCandidate scene, Actor actor) - { - if (scene == null || actor == null || !actor.isAlive()) - { - return false; - } - - try - { - Vector3 anchor = scene.Position; - if (scene.FollowUnit != null && scene.FollowUnit.isAlive() && scene.FollowUnit != actor) - { - // Prefer sticky scrap anchor when follow already hopped. - } - - float dx = actor.current_position.x - anchor.x; - float dy = actor.current_position.y - anchor.y; - float r = CombatTheaterLeadNearRadius; - return dx * dx + dy * dy <= r * r; - } - catch - { - return false; - } - } - - private static bool SameStickySide(InterestCandidate scene, Actor a, Actor b) - { - if (scene?.Sticky == null || a == null || b == null) - { - return false; - } - - long idA = EventFeedUtil.SafeId(a); - long idB = EventFeedUtil.SafeId(b); - if (idA == 0 || idB == 0) - { - return false; - } - - LiveSceneStickyState sticky = scene.Sticky; - bool aOnA = false; - bool aOnB = false; - bool bOnA = false; - bool bOnB = false; - for (int i = 0; i < sticky.SideAIds.Count; i++) - { - if (sticky.SideAIds[i] == idA) - { - aOnA = true; - } - - if (sticky.SideAIds[i] == idB) - { - bOnA = true; - } - } - - for (int i = 0; i < sticky.SideBIds.Count; i++) - { - if (sticky.SideBIds[i] == idA) - { - aOnB = true; - } - - if (sticky.SideBIds[i] == idB) - { - bOnB = true; - } - } - - return (aOnA && bOnA) || (aOnB && bOnB); - } - - private static int SideCountForActor(InterestCandidate scene, Actor actor) - { - if (scene?.Sticky == null || actor == null) - { - return 1; - } - - long id = EventFeedUtil.SafeId(actor); - LiveSceneStickyState sticky = scene.Sticky; - for (int i = 0; i < sticky.SideAIds.Count; i++) - { - if (sticky.SideAIds[i] == id) - { - return Mathf.Max(1, sticky.SideACount); - } - } - - for (int i = 0; i < sticky.SideBIds.Count; i++) - { - if (sticky.SideBIds[i] == id) - { - return Mathf.Max(1, sticky.SideBCount); - } - } - - return Mathf.Max(1, sticky.SideACount); - } - - private static int SideCountForOpponent(InterestCandidate scene, Actor actor) - { - if (scene?.Sticky == null || actor == null) - { - return 1; - } - - long id = EventFeedUtil.SafeId(actor); - LiveSceneStickyState sticky = scene.Sticky; - for (int i = 0; i < sticky.SideAIds.Count; i++) - { - if (sticky.SideAIds[i] == id) - { - return Mathf.Max(1, sticky.SideBCount); - } - } - - return Mathf.Max(1, sticky.SideACount); - } - - private static Actor OppositeSideBest(InterestCandidate scene, Actor actor) - { - if (scene?.Sticky == null || actor == null) - { - return null; - } - - long id = EventFeedUtil.SafeId(actor); - LiveSceneStickyState sticky = scene.Sticky; - bool onA = false; - for (int i = 0; i < sticky.SideAIds.Count; i++) - { - if (sticky.SideAIds[i] == id) - { - onA = true; - break; - } - } - - return LiveEnsemble.FindBestAliveTracked( - onA ? sticky.SideBIds : sticky.SideAIds, - preferCombat: true); - } - - /// - /// Pick a living sticky-roster fighter when the current follow left the scrap. - /// - private static bool TryRetargetCombatFollow( - InterestCandidate scene, - out Actor fighter, - out Actor foe) - { - fighter = null; - foe = null; - if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) - { - return false; - } - - LiveSceneStickyState sticky = scene.Sticky; - Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true); - Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true); - bool aFighting = LiveEnsemble.IsCombatParticipant(pickA); - bool bFighting = LiveEnsemble.IsCombatParticipant(pickB); - if (!aFighting && !bFighting) - { - return false; - } - - if (aFighting && bFighting) - { - float wA = LiveEnsemble.FocusWeight(pickA, scene.ParticipantIds, 0f); - float wB = LiveEnsemble.FocusWeight(pickB, scene.ParticipantIds, 0f); - if (wB > wA) - { - fighter = pickB; - foe = pickA; - } - else - { - fighter = pickA; - foe = pickB; - } - } - else if (aFighting) - { - fighter = pickA; - foe = pickB; - } - else - { - fighter = pickB; - foe = pickA; - } - - return fighter != null && fighter.isAlive(); - } - - /// - /// Allow leaving NamedPair only when a real sided multi owns the scrap - /// (species/kingdom camps with 3+ fighters), never from ambient radius noise. - /// - private static bool ShouldEscalateNamedPair( - InterestCandidate scene, - LiveEnsemble ensemble, - int fighters) - { - if (scene == null) - { - return false; - } - - string tip = scene.Label ?? ""; - bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); - - ResolveCombatPairActors(scene, out Actor owner, out Actor partner); - Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit; - Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit; - if (focus == null || !focus.isAlive() || related == null || !related.isAlive()) - { - return false; - } - - bool pairEngaged = LiveEnsemble.IsCombatParticipant(focus) - || LiveEnsemble.IsCombatParticipant(related); - - // Already-framed collective multi may leave NamedPair. Duel tips still escalate when - // sticky camps were enrolled (AI often drops attack_target on pack extras). - if (HasStickyCollectiveMulti(scene)) - { - if (!duelLabeled) - { - return true; - } - - int stickyN = scene.CombatSideACount + scene.CombatSideBCount; - if (stickyN >= 3 && pairEngaged) - { - return true; - } - } - - if (fighters < 3 || ensemble == null) - { - return false; - } - - bool sidedMulti = LiveEnsemble.HasOpposingCollectiveSides(ensemble) - || ensemble.Scale >= EnsembleScale.Skirmish; - if (!sidedMulti) - { - return false; - } - - // Probe-only escalate needs a real pack on both sides. A single attack_target - // distractor (Skirmish 2v1) is partner-swap noise and must keep the Duel lock. - int sideA = ensemble.SideA != null ? ensemble.SideA.Count : 0; - int sideB = ensemble.SideB != null ? ensemble.SideB.Count : 0; - if (sideA < 2 || sideB < 2) - { - return false; - } - - // Natural growth: pair still owns a living scrap inside a sided multi. - // Attack-target gaps on one partner must not block Duel → Mass/Battle. - return pairEngaged; - } - - /// - /// First partner died while the owner is still fighting: reframe without naming the - /// next attack_target (spectacle 1vN / scrap mop). Prefer collective tips when present. - /// - private static bool TryHoldOwnerAfterPartnerDeath( - InterestCandidate scene, - Actor ownedFocus, - LiveEnsemble ensemble, - ref Actor best, - ref Actor foe, - ref int fighters, - ref string label) - { - if (scene == null || scene.PairPartnerId == 0) - { - return false; - } - - // Alive-only check: corpses / destroyed refs must not keep a NamedPair tip. - if (EventFeedUtil.FindAliveById(scene.PairPartnerId) != null) - { - return false; - } - - Actor owner = ownedFocus != null && ownedFocus.isAlive() - ? ownedFocus - : EventFeedUtil.FindAliveById(scene.PairOwnerId); - if (owner == null || !owner.isAlive()) - { - return false; - } - - // Partner lock points at a dead unit. Collective Mass/Battle/Skirmish tips already - // own the scrap - never collapse them into thin "is fighting". - string prior = scene.Label ?? ""; - bool alreadyCollective = prior.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) - || prior.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) - || prior.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase); - if (alreadyCollective - || HasStickyCollectiveMulti(scene) - || (ensemble != null - && (ensemble.ParticipantCount >= 3 - || LiveEnsemble.HasOpposingCollectiveSides(ensemble) - || ensemble.Scale >= EnsembleScale.Skirmish))) - { - return false; - } - - // Thin NamedPair mop-up: never name a revolving Duel partner while the first - // partner lock still points at a dead unit. - best = owner; - foe = null; - fighters = Mathf.Max(1, fighters); - label = EventReason.Fight(owner, null); - scene.StampTheaterLead(owner); - return !string.IsNullOrEmpty(label); - } - - /// - /// When the tip is (or collapses to) a NamedPair Duel, keep the locked living partner - /// instead of adopting the owner's latest attack_target. - /// - private static bool TryRestoreLockedDuelPartner( - InterestCandidate scene, - Actor ownedFocus, - Actor ownedRelated, - ref Actor best, - ref Actor foe, - ref string label) - { - if (scene == null) - { - return false; - } - - Actor partner = ownedRelated; - if (partner == null || !partner.isAlive()) - { - if (scene.PairPartnerId != 0) - { - partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); - } - } - - if (partner == null || !partner.isAlive()) - { - return false; - } - - bool duelTip = !string.IsNullOrEmpty(label) - && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); - bool thinFight = !string.IsNullOrEmpty(label) - && label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0; - if (!duelTip && !thinFight) - { - return false; - } - - if (foe == partner && (ownedFocus == null || best == ownedFocus || best == partner)) - { - return false; - } - - Actor focus = ownedFocus != null && ownedFocus.isAlive() - ? ownedFocus - : (best != null && best.isAlive() ? best : scene.FollowUnit); - if (focus == null || !focus.isAlive() || focus == partner) - { - return false; - } - - best = focus; - foe = partner; - int holdFighters = 2; - ApplyNamedPairHold(scene, focus, partner, ref best, ref foe, ref holdFighters, ref label); - return true; - } - - private static void ApplyNamedPairHold( - InterestCandidate scene, - Actor ownedFocus, - Actor ownedRelated, - ref Actor best, - ref Actor foe, - ref int fighters, - ref string label) - { - if (scene == null || ownedFocus == null || !ownedFocus.isAlive()) - { - return; - } - - // Drop thin ambient species/kingdom sticky that leaked in during cluster Refresh. - // Keep 3+ enrolled camps - those are intentional escalate seeds (wire / pack growth). - int enrolled = scene.CombatSideACount + scene.CombatSideBCount; - if (HasStickyCombatSides(scene) - && enrolled < 3 - && (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies - || scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom)) - { - scene.ClearCombatSticky(); - } - - best = ownedFocus; - foe = ResolvePairPartner(ownedFocus, ownedRelated, foe); - fighters = Mathf.Max(2, Math.Min(fighters, 2)); - scene.ParticipantCount = Mathf.Max(2, scene.ParticipantCount); - if (string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) - && scene.CombatPeakParticipants <= 2) - { - scene.AssetId = "live_combat"; - } - - var pairHold = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = EnsembleScale.Pair, - Frame = EnsembleFrame.NamedPair, - ParticipantCount = 2, - Focus = best, - Related = foe - }; - label = EventReason.Combat(pairHold); - if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(scene.Label)) - { - label = scene.Label; - } - } - - private static Actor ResolvePairPartner(Actor focus, Actor ownedRelated, Actor rebuiltFoe) - { - if (ownedRelated != null && ownedRelated.isAlive() && ownedRelated != focus) - { - return ownedRelated; - } - - Actor attack = ResolveAttackFoe(focus); - if (attack != null && attack != focus) - { - return attack; - } - - if (rebuiltFoe != null && rebuiltFoe.isAlive() && rebuiltFoe != focus) - { - return rebuiltFoe; - } - - return null; - } - - /// - /// Hold Duel ownership whenever Follow+Related still form the live pair. - /// Ambient sticky camps do not unlock a living NamedPair. - /// - private static bool ShouldHoldDuelOwnership( - InterestCandidate scene, - Actor best, - Actor foe, - int fighters, - Actor ownedFocus = null, - Actor ownedRelated = null) - { - if (scene == null) - { - return false; - } - - Actor curFocus = ownedFocus != null && ownedFocus.isAlive() - ? ownedFocus - : scene.FollowUnit; - Actor curRelated = ownedRelated != null && ownedRelated.isAlive() - ? ownedRelated - : scene.RelatedUnit; - if (curFocus == null || !curFocus.isAlive() - || curRelated == null || !curRelated.isAlive()) - { - return false; - } - - bool duelLabeled = !string.IsNullOrEmpty(scene.Label) - && scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); - - // Ambient sticky camps must not unlock a living Duel tip (A↔B flip). - // Real multi leave is owned by ShouldEscalateNamedPair / theater lead, not this hold. - if (HasStickyCollectiveMulti(scene) && !duelLabeled) - { - return false; - } - - bool smallCluster = fighters <= 2; - if (!duelLabeled && !smallCluster && !scene.ForceActive) - { - return false; - } - - // Rebuilt focus/foe is the same two actors (any order), or only one of them - // remains visible in the cluster (foe briefly missing). - return IsSameCombatPair(curFocus, curRelated, best, foe); - } - - /// - /// True when / are the same two actors as the - /// current pair (possibly swapped). Used to lock Duel tip ownership. - /// - private static bool IsSameCombatPair(Actor curFocus, Actor curRelated, Actor best, Actor foe) - { - if (curFocus == null || !curFocus.isAlive() || best == null || !best.isAlive()) - { - return false; - } - - if (best == curFocus) - { - return foe == null - || foe == curRelated - || foe == curFocus - || (curRelated == null && ResolveAttackFoe(curFocus) == foe); - } - - if (best == curRelated) - { - return foe == null || foe == curFocus || foe == curRelated; - } - - Actor liveFoe = ResolveAttackFoe(curFocus) ?? curRelated; - return liveFoe != null && (best == liveFoe) && (foe == null || foe == curFocus); - } private static Actor ResolveAttackFoe(Actor actor) { @@ -3147,168 +1443,6 @@ public static class InterestDirector return true; } - private static void ClearStaleCombatLabel(float now) - { - if (_current == null || string.IsNullOrEmpty(_current.Label)) - { - return; - } - - string label = _current.Label; - if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0 - && label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0) - { - return; - } - - _current.Label = ""; - _current.ClearCombatSticky(); - _current.ClearTheaterLead(); - _lastCombatFocusAt = now; - CameraDirector.Watch(_current.ToInterestEvent()); - } - - /// - /// Build a sticky combat tip ensemble with the theater-lead side listed first so - /// Mass/Battle wording does not flip A↔B across maintain ticks. - /// - private static void BuildStickyCombatTipEnsemble( - InterestCandidate scene, - Actor focus, - Actor related, - out LiveEnsemble stickyEns) - { - LiveSceneStickyState sticky = scene?.Sticky; - if (sticky != null) - { - StickyScoreboard.GetPresentationCounts(sticky, out int countA, out int countB); - bool mopUp = sticky.MopUpActive || countA <= 0 || countB <= 0; - int liveN = Math.Max(0, countA) + Math.Max(0, countB); - int scaleN = mopUp ? Math.Max(1, liveN) : Math.Max(3, scene.CombatPeakParticipants); - stickyEns = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = LiveEnsemble.ScaleForCount(scaleN), - Focus = focus, - Related = related, - Frame = scene.CombatSideFrame, - ParticipantCount = liveN - }; - - var sideA = new EnsembleSide - { - Key = scene.CombatSideAKey, - Display = scene.CombatSideADisplay, - KingdomDisplay = scene.CombatSideAKingdom, - Count = countA, - Best = focus - }; - var sideB = new EnsembleSide - { - Key = scene.CombatSideBKey, - Display = scene.CombatSideBDisplay, - KingdomDisplay = scene.CombatSideBKingdom, - Count = countB, - Best = related - }; - - bool focusOnB = false; - if (focus != null) - { - long id = EventFeedUtil.SafeId(focus); - for (int i = 0; i < sticky.SideBIds.Count; i++) - { - if (sticky.SideBIds[i] == id) - { - focusOnB = true; - break; - } - } - } - - if (focusOnB) - { - stickyEns.SideA = sideB; - stickyEns.SideA.Best = focus; - stickyEns.SideB = sideA; - stickyEns.SideB.Best = related; - } - else - { - stickyEns.SideA = sideA; - stickyEns.SideB = sideB; - } - - return; - } - - stickyEns = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants)), - Focus = focus, - Related = related, - Frame = scene.CombatSideFrame, - ParticipantCount = scene.CombatSideACount + scene.CombatSideBCount - }; - - var sideAFallback = new EnsembleSide - { - Key = scene.CombatSideAKey, - Display = scene.CombatSideADisplay, - KingdomDisplay = scene.CombatSideAKingdom, - Count = scene.CombatSideACount, - Best = focus - }; - var sideBFallback = new EnsembleSide - { - Key = scene.CombatSideBKey, - Display = scene.CombatSideBDisplay, - KingdomDisplay = scene.CombatSideBKingdom, - Count = scene.CombatSideBCount, - Best = related - }; - - stickyEns.SideA = sideAFallback; - stickyEns.SideB = sideBFallback; - } - - /// Harness: force one combat focus maintenance pass. - public static void HarnessMaintainCombatFocus() - { - MaintainCombatFocus(Time.unscaledTime, force: true); - } - - /// Harness: force one war-front maintenance pass. - public static void HarnessMaintainWarFront() - { - MaintainWarFront(Time.unscaledTime, force: true); - } - - /// Harness: force one plot-cell maintenance pass. - public static void HarnessMaintainPlotCell() - { - MaintainPlotCell(Time.unscaledTime, force: true); - } - - /// Harness: force one family-pack maintenance pass. - public static void HarnessMaintainFamilyPack() - { - MaintainFamilyPack(Time.unscaledTime, force: true); - } - - /// Harness: force one status-outbreak maintenance pass. - public static void HarnessMaintainStatusOutbreak() - { - MaintainStatusOutbreak(Time.unscaledTime, force: true); - } /// Harness: apply a synthetic war ensemble onto the current WarFront scene. public static bool HarnessApplyWarEnsemble(LiveEnsemble ensemble) @@ -3781,6 +1915,7 @@ public static class InterestDirector float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; + bool softQuiet = StoryPlanner.SoftFillQuietActive; for (int i = PendingScratch.Count - 1; i >= 0; i--) { InterestCandidate c = PendingScratch[i]; @@ -3790,6 +1925,17 @@ public static class InterestDirector continue; } + // After hard story/crisis closers, skip soft-life crumbs for a beat so AFK + // does not immediately hatch/reproduce/sleep surf. Leave them pending. + if (softQuiet + && InterestVariety.IsSoftLifeChapter(c) + && !BreaksSoftFillQuiet(c)) + { + InterestDropLog.Record("soft_fill_quiet", c.AssetId ?? c.HappinessEffectId ?? c.Key); + PendingScratch.RemoveAt(i); + continue; + } + // Drop stale / not-worth-watching / unpresentable shots from the pending set. // Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests. bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase); @@ -3817,6 +1963,43 @@ public static class InterestDirector return InterestVariety.Pick(PendingScratch, _current); } + /// + /// Hard / sticky / epic tips may still cut during soft-fill quiet. + /// + private static bool BreaksSoftFillQuiet(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive + || c.Completion == InterestCompletionKind.StatusOutbreak + || c.Completion == InterestCompletionKind.EarthquakeActive + || c.Completion == InterestCompletionKind.FamilyPack) + { + return true; + } + + if (string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase) + || StoryReason.IsStoryAsset(c.AssetId)) + { + return true; + } + + float epicFloor = InterestScoringConfig.Story.crisisDisasterStrengthMin > 0f + ? InterestScoringConfig.Story.crisisDisasterStrengthMin + : 95f; + if (c.EventStrength >= epicFloor) + { + return true; + } + + return false; + } + private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now) { if (c == null || string.IsNullOrEmpty(c.HappinessEffectId)) @@ -4184,256 +2367,6 @@ public static class InterestDirector return true; } - private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch) - { - if (candidate == null) - { - return false; - } - - // Same FixedDwell beat already watched on this subject - never cut back in. - if (InterestVariety.IsBeatCooled(candidate)) - { - InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key); - return false; - } - - if (_current == null) - { - return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); - } - - float now = Time.unscaledTime; - - // Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap. - if (IsCombatPartnerSwapNoise(_current, candidate)) - { - InterestDropLog.Record( - "combat_partner_hold", - $"cur={_current.Key} next={candidate.Key}"); - return false; - } - - // War tip owns the camera against Mass/Battle scraps. Must run before crisis cut-in: - // a leftover war crisis can MatchCrisis(Mass) while a small WarFront tip does not. - if (_current.Completion == InterestCompletionKind.WarFront - && candidate.Completion == InterestCompletionKind.CombatActive) - { - InterestDropLog.Record( - "war_theater_hold", - $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); - return false; - } - - // Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars - // against aftermath / ambient peers that outscore the closer on raw EventStrength. - if (StoryPlanner.CrisisActive - && StoryPlanner.MatchesCrisis(candidate) - && !StoryPlanner.MatchesCrisis(_current) - && IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace)) - { - return true; - } - - // Live fight: hold until cold (see who wins), unless something extremely urgent cuts in. - // Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom - // theater may reclaim the chapter (Mass → War) without waiting for combat cold. - if (CombatFightIsHot(_current, now) - && !IsSameStoryArc(_current, candidate) - && candidate.Completion != InterestCompletionKind.CombatActive) - { - bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront - && StickyScoreboard.SharesKingdomTheater(_current, candidate); - if (warTheaterReclaim) - { - return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); - } - - if (IsCombatUrgentPeer(_current, candidate)) - { - InterestDropLog.Record( - "combat_urgent_cut", - $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); - return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); - } - - InterestDropLog.Record( - "combat_hold", - $"cur={_current.Key} next={candidate.Key}"); - return false; - } - - bool inGrace = InQuietGrace; - if (inGrace) - { - // Story aftermath / same-arc continuation may cut freely during quiet grace. - if (IsSameStoryArc(_current, candidate) - && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true)) - { - return true; - } - - // Quiet grace after a sticky hold: only margin-worthy events may cut in. - // Prevents family-join / love status from stealing a fight between swings. - if (_current != null - && (_current.Completion == InterestCompletionKind.CombatActive - || _current.Completion == InterestCompletionKind.StatusPhase - || _current.Completion == InterestCompletionKind.HappinessGrief - || IsStickyStoryScene(_current))) - { - float graceCur = _current.TotalScore; - float graceNext = candidate.TotalScore; - float onCurrentGrace = now - _currentStartedAt; - ScoringWeights gw = InterestScoringConfig.W; - float graceMargin = IsSoftStickyCluster(_current) - ? gw.cutInMargin - : Mathf.Max( - gw.cutInMargin, - gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f); - if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate)) - { - float valveMargin = gw.stickyVarietyValveMargin > 0f - ? gw.stickyVarietyValveMargin - : 20f; - graceMargin = Mathf.Min(graceMargin, valveMargin); - if (graceNext >= graceCur - gw.rotateSlack - && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true)) - { - return true; - } - } - - return graceNext >= graceCur + graceMargin - && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); - } - - return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); - } - - if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false)) - { - return false; - } - - bool protectedScene = SessionProtected(now, onCurrent); - ScoringWeights w = InterestScoringConfig.W; - float curScore = _current.TotalScore; - float nextScore = candidate.TotalScore; - bool nextFill = InterestScoring.IsFillScore(nextScore); - bool curAmbient = IsAmbientShot(_current); - bool curFill = curAmbient || InterestScoring.IsFillScore(curScore); - bool sticky = IsStickyStoryScene(_current); - float cutMargin = sticky - ? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f) - : w.cutInMargin; - bool varietyValve = VarietyValveOpen(_current, onCurrent) - && IsVarietyValveCandidate(candidate); - if (varietyValve) - { - float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f; - cutMargin = Mathf.Min(cutMargin, valveMargin); - } - - // Ambient fill: any real event may take the camera immediately (no min dwell). - if (curAmbient && !nextFill && !IsAmbientShot(candidate)) - { - return true; - } - - // Soft family pack: yield to discrete unit events after a short hold. - if (IsSoftStickyCluster(_current) - && !IsSoftStickyCluster(candidate) - && !nextFill - && !IsAmbientShot(candidate) - && onCurrent >= SoftClusterYieldSeconds - && nextScore >= curScore - w.rotateSlack) - { - return true; - } - - // Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut. - // Score-margin alone can never clear Mass (~150) with lover (~78); use class gate. - if (varietyValve && !nextFill && !IsAmbientShot(candidate)) - { - bool hotEnough = InterestScoring.IsHotScore(nextScore) - || nextScore >= curScore - w.rotateSlack; - bool storyEnough = nextScore >= w.noticeScoreMin - || candidate.EventStrength >= w.noticeScoreMin; - if (hotEnough || storyEnough) - { - InterestDropLog.Record( - "variety_valve", - $"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}"); - return true; - } - } - - // Same-arc refresh (combat/hatch/grief keys) may replace without full margin. - if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack) - { - return true; - } - - // StoryPlanner hard-arc / crisis hold against unrelated peers. - float storyHold = Mathf.Max( - StoryPlanner.ArcHoldMargin(_current, candidate), - StoryPlanner.CrisisHoldMargin(_current, candidate)); - if (storyHold > 0f) - { - cutMargin = Mathf.Max(cutMargin, storyHold); - } - - // Instant score-margin cut - no settle grace, no MinDwell block. - // Soft clusters use the normal cut-in margin (not stickyCutInMargin). - float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin; - if (nextScore >= curScore + effectiveMargin) - { - return true; - } - - if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin) - { - InterestDropLog.Record( - "below_margin", - $"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}" - + (varietyValve ? " valve" : "") - + (storyHold > 0f ? " story" : "")); - } - - // Fill never cuts a protected non-fill session without margin (already failed above). - if (nextFill && !curFill && protectedScene) - { - return false; - } - - float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds; - if (nextFill && curFill && onCurrent < fillRotate) - { - return false; - } - - // Protected / sticky EventLed hold: only margin or same-arc (handled above). - if ((protectedScene || sticky) && !curAmbient) - { - return false; - } - - // Peer rotate / stronger score after MinDwell when unprotected or on fill. - if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown) - { - return true; - } - - return onCurrent >= MinDwellFor(_current) - && sinceSwitch >= _switchCooldown - && nextScore >= curScore - w.rotateSlack; - } - - /// Soft multi-actor holds that must yield to discrete unit events. - private const float SoftClusterYieldSeconds = 4f; - - private static bool IsSoftStickyCluster(InterestCandidate c) => - c != null && c.Completion == InterestCompletionKind.FamilyPack; /// True while a CombatActive scene still has a live scrap (incl. hysteresis). private static bool CombatFightIsHot(InterestCandidate scene, float now) @@ -4451,85 +2384,6 @@ public static class InterestDirector return InterestCompletion.IsActive(scene, _currentStartedAt, now); } - /// - /// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world - /// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers). - /// - private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next) - { - if (current == null || next == null) - { - return false; - } - - if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next)) - { - return false; - } - - ScoringWeights w = InterestScoringConfig.W; - float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f; - if (next.TotalScore >= current.TotalScore + stickyMargin) - { - return true; - } - - // King / leadership deaths sit under the epic floor (king_killed=88) but must cut - // live scraps - soak missed a king slaying while parked on a Duel thrash. - if (IsLeadershipDeathPeer(next)) - { - return true; - } - - float epicMin = w.combatUrgentEventStrengthMin > 0f - ? w.combatUrgentEventStrengthMin - : 95f; - float urgentMargin = w.combatUrgentCutInMargin > 0f - ? w.combatUrgentCutInMargin - : 20f; - return next.EventStrength >= epicMin - && next.TotalScore >= current.TotalScore + urgentMargin; - } - - /// - /// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers. - /// - private static bool IsLeadershipDeathPeer(InterestCandidate c) - { - if (c == null) - { - return false; - } - - string id = (c.AssetId ?? "").ToLowerInvariant(); - if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0 - || id.IndexOf("king_dead", StringComparison.Ordinal) >= 0 - || id.IndexOf("king_died", StringComparison.Ordinal) >= 0 - || id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0 - || id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0) - { - return true; - } - - if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return id.IndexOf("kill", StringComparison.Ordinal) >= 0 - || id.IndexOf("dead", StringComparison.Ordinal) >= 0 - || id.IndexOf("died", StringComparison.Ordinal) >= 0 - || id.IndexOf("slain", StringComparison.Ordinal) >= 0; - } - - /// - /// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in. - /// Live CombatActive scraps never open the valve - hold until the fight goes cold. - /// - private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) => - !_varietyValveUsed - && VarietyValveTimeReady(scene, onCurrent) - && !CombatFightIsHot(scene, Time.unscaledTime); private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent) { @@ -4659,322 +2513,6 @@ public static class InterestDirector || id.IndexOf("infect", StringComparison.OrdinalIgnoreCase) >= 0; } - private static bool IsStickyStoryScene(InterestCandidate c) - { - if (c == null) - { - return false; - } - - // FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster). - if (c.Completion == InterestCompletionKind.CombatActive - || c.Completion == InterestCompletionKind.WarFront - || c.Completion == InterestCompletionKind.PlotActive - || c.Completion == InterestCompletionKind.StatusPhase - || c.Completion == InterestCompletionKind.StatusOutbreak - || c.Completion == InterestCompletionKind.HappinessGrief) - { - return true; - } - - // Hatch FixedDwell is a story beat - hold against weak peers. - if (!string.IsNullOrEmpty(c.HappinessEffectId) - && (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) - || string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - if (!string.IsNullOrEmpty(c.Key) - && (c.Key.StartsWith("combat:", StringComparison.Ordinal) - || c.Key.StartsWith("hatch:", StringComparison.Ordinal))) - { - return true; - } - - return false; - } - - private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next) - { - if (current == null || next == null) - { - return false; - } - - if (!string.IsNullOrEmpty(current.Key) - && !string.IsNullOrEmpty(next.Key) - && string.Equals(current.Key, next.Key, StringComparison.Ordinal)) - { - return true; - } - - // Hatch: same subject beat may refresh without full margin. - if (!string.IsNullOrEmpty(current.Key) - && !string.IsNullOrEmpty(next.Key) - && SameKeyPrefix(current.Key, next.Key, "hatch:")) - { - return true; - } - - // Combat: only the same unordered pair (or escalate of that scrap) is same-arc. - // Different combat:pair keys used to soft-cut and thrash Duel partners. - if (current.Completion == InterestCompletionKind.CombatActive - && next.Completion == InterestCompletionKind.CombatActive - && IsSameCombatPairArc(current, next)) - { - return true; - } - - // Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut). - if (current.Completion == InterestCompletionKind.CombatActive - && next.Completion == InterestCompletionKind.WarFront - && StickyScoreboard.SharesKingdomTheater(current, next)) - { - return true; - } - - if (current.Completion == InterestCompletionKind.WarFront - && next.Completion == InterestCompletionKind.WarFront - && StickyScoreboard.SharesKingdomTheater(current, next)) - { - return true; - } - - if (current.Completion == InterestCompletionKind.HappinessGrief - && next.Completion == InterestCompletionKind.HappinessGrief - && current.SubjectId != 0 - && current.SubjectId == next.SubjectId) - { - return true; - } - - // StoryPlanner aftermath / epilogue of the active climax. - if (StoryPlanner.IsContinuationOf(current, next)) - { - return true; - } - - return false; - } - - /// - /// True when next is the same 1v1 pair or a multi escalate of the current scrap. - /// - private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next) - { - if (current == null || next == null) - { - return false; - } - - if (!string.IsNullOrEmpty(current.Key) - && !string.IsNullOrEmpty(next.Key) - && string.Equals(current.Key, next.Key, StringComparison.Ordinal)) - { - return true; - } - - CollectCombatActorIds(current, out long curA, out long curB); - CollectCombatActorIds(next, out long nextA, out long nextB); - bool samePair = curA != 0 - && curB != 0 - && nextA != 0 - && nextB != 0 - && ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA)); - if (samePair) - { - return true; - } - - // Duel → Mass/Battle escalate: shared fighter + larger theater. - bool nextMulti = next.ParticipantCount >= 3 - || string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) - || HasStickyCollectiveMulti(next); - if (!nextMulti) - { - return false; - } - - return SharesCombatFighterId(current, nextA) - || SharesCombatFighterId(current, nextB) - || SharesCombatFighterId(next, curA) - || SharesCombatFighterId(next, curB); - } - - /// - /// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold. - /// - private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next) - { - if (current == null - || next == null - || current.Completion != InterestCompletionKind.CombatActive - || next.Completion != InterestCompletionKind.CombatActive) - { - return false; - } - - // Multi escalate / different theater is not partner-swap noise. - bool nextMulti = next.ParticipantCount >= 3 - || HasStickyCollectiveMulti(next) - || (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) - && next.ParticipantCount > 2); - if (nextMulti && !IsThinPairCombat(next)) - { - return false; - } - - CollectCombatActorIds(current, out long curA, out long curB); - CollectCombatActorIds(next, out long nextA, out long nextB); - if (curA == 0 && curB == 0 - && current.TheaterLeadId == 0 - && current.PairOwnerId == 0 - && current.SubjectId == 0) - { - return false; - } - - // Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise. - if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current)) - { - if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB)) - { - return true; - } - - return false; - } - - if (!IsThinPairCombat(next)) - { - return false; - } - - // Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise. - // Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live. - bool sameUnorderedPair = curA != 0 - && curB != 0 - && nextA != 0 - && nextB != 0 - && ((curA == nextA && curB == nextB) - || (curA == nextB && curB == nextA)); - if (sameUnorderedPair) - { - long curSub = current.SubjectId != 0 - ? current.SubjectId - : (current.PairOwnerId != 0 - ? current.PairOwnerId - : EventFeedUtil.SafeId(current.FollowUnit)); - long nextSub = next.SubjectId != 0 - ? next.SubjectId - : EventFeedUtil.SafeId(next.FollowUnit); - if (curSub != 0 && nextSub != 0 && curSub != nextSub) - { - return true; - } - - return false; - } - - // Shared fighter + different partner = attack_target thrash. - return SharesCombatOwnerOrLead(current, nextA, nextB) - || SharesCombatFighterId(current, nextA) - || SharesCombatFighterId(current, nextB); - } - - private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB) - { - if (current == null) - { - return false; - } - - long lead = current.TheaterLeadId != 0 - ? current.TheaterLeadId - : (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId); - if (lead == 0) - { - lead = EventFeedUtil.SafeId(current.FollowUnit); - } - - return lead != 0 && (lead == nextA || lead == nextB); - } - - private static bool IsThinPairCombat(InterestCandidate c) - { - if (c == null) - { - return false; - } - - if (c.HasCombatPairLock || c.ParticipantCount <= 2) - { - return true; - } - - string tip = c.Label ?? ""; - if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) - || tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0) - { - return true; - } - - string key = c.Key ?? ""; - return key.StartsWith("combat:pair:", StringComparison.Ordinal) - || (key.StartsWith("combat:", StringComparison.Ordinal) - && key.IndexOf(":pair:", StringComparison.Ordinal) < 0 - && !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)); - } - - private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b) - { - a = 0; - b = 0; - if (c == null) - { - return; - } - - a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId; - b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId; - if (a == 0) - { - a = EventFeedUtil.SafeId(c.FollowUnit); - } - - if (b == 0) - { - b = EventFeedUtil.SafeId(c.RelatedUnit); - } - } - - private static bool SharesCombatFighterId(InterestCandidate c, long id) - { - if (c == null || id == 0) - { - return false; - } - - CollectCombatActorIds(c, out long a, out long b); - return id == a || id == b; - } - - private static bool SameKeyPrefix(string a, string b, string prefix) - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix)) - { - return false; - } - - if (!a.StartsWith(prefix, StringComparison.Ordinal) - || !b.StartsWith(prefix, StringComparison.Ordinal)) - { - return false; - } - - return string.Equals(a, b, StringComparison.Ordinal); - } /// /// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 88eec2f..21ed75a 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -269,7 +269,9 @@ public static class InterestRegistry bool match = string.IsNullOrEmpty(needle) || (c.Key != null - && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0); + && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + || (c.AssetId != null + && c.AssetId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0); if (match) { c.ExpiresAt = now - 0.01f; diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index 47122c1..c6f2f86 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -160,7 +160,7 @@ public static class InterestScoring float repeatPenalty = InterestVariety.RepeatArcPenalty(c); float frequencyAdjust = InterestVariety.FrequencyAdjust(c); float causalBonus = CausalHeat.ScoreBonus(c); - float ledgerBonus = CharacterLedger.ScoreBonus(c); + float sagaBonus = LifeSagaRoster.ScoreBonus(c); float ownershipBonus = StoryPlanner.OwnershipBoost(c); c.TotalScore = c.EventStrength + scaleBonus + c.CharacterSignificance * charWeight @@ -169,7 +169,7 @@ public static class InterestScoring - repeatPenalty + frequencyAdjust + causalBonus - + ledgerBonus + + sagaBonus + ownershipBonus; string detail = $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}"; @@ -190,9 +190,9 @@ public static class InterestScoring detail += $" causal=+{causalBonus:0.#}"; } - if (ledgerBonus > 0.05f) + if (sagaBonus > 0.05f) { - detail += $" ledger=+{ledgerBonus:0.#}"; + detail += $" saga=+{sagaBonus:0.#}"; } if (ownershipBonus > 0.05f) diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 9fd3e80..4322a67 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -36,10 +36,18 @@ public class StoryWeights public int ledgerCap = 16; public float ledgerWeight = 8f; public float historyDensityWindowSeconds = 600f; - /// Multiply subject ledger heat after a family life chapter tip is shown. + /// Obsolete ledger bleed (CharacterLedger removed); kept for json compat. public float ledgerBleedLifeChapter = 0.4f; - /// Multiply other ledger entries after a family life chapter (village cool). + /// Obsolete ledger bleed (CharacterLedger removed); kept for json compat. public float ledgerBleedVillage = 0.82f; + /// Life-saga roster soft MC tip bonus. + public float sagaMcWeight = 6f; + /// Extra soft bonus when the saga slot is Prefer'd. + public float sagaPreferWeight = 10f; + /// Extra soft bonus when the unit is a WorldBox favorite. + public float sagaGameFavoriteWeight = 8f; + /// Chapter starvation window before dull non-favorite MCs lose rank. + public float sagaDullSeconds = 300f; /// Extra FixedDwell beat cool for family chapters (seconds, stacks with base). public float familyLifeBeatCooldownSeconds = 90f; @@ -70,6 +78,11 @@ public class StoryWeights public float crisisEpilogueStrength = 52f; /// Forced closer max watch (seconds). public float crisisEpilogueMaxWatch = 16f; + /// + /// After a hard story/crisis closer, suppress soft-life crumbs and character-fill + /// for this many seconds so AFK does not immediately crumb-surf. + /// + public float softFillQuietSeconds = 18f; } /// Serializable weight block — field names must match scoring-model.json "weights". diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 6423368..0fcea53 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -164,7 +164,7 @@ public static class InterestVariety } // Soft life chapters cool ledger heat so the same villagers do not monopolize. - CharacterLedger.CoolAfterSoftLifeBeat(chosen); + // Soft-life cool no longer demotes saga MCs (CharacterLedger removed). } /// @@ -727,7 +727,33 @@ public static class InterestVariety return Ranked[0]; } - // Probabilistic among top-N only when scores are within epsilon. + // Near-tie: Prefer'd MC, then any MC, else random among top-N. + InterestCandidate bestSaga = null; + int bestRank = -1; + for (int i = 0; i < topN; i++) + { + if (Ranked[0].TotalScore - Ranked[i].TotalScore >= epsilon) + { + break; + } + + int rank = LifeSagaRoster.TipTouchesPrefer(Ranked[i]) + ? 2 + : LifeSagaRoster.TipTouchesMc(Ranked[i]) + ? 1 + : 0; + if (rank > bestRank) + { + bestRank = rank; + bestSaga = Ranked[i]; + } + } + + if (bestSaga != null && bestRank > 0) + { + return bestSaga; + } + int pick = Rng.Next(topN); return Ranked[pick]; } diff --git a/IdleSpectator/SpectatorMode.cs b/IdleSpectator/SpectatorMode.cs index 9f74d8e..d93a898 100644 --- a/IdleSpectator/SpectatorMode.cs +++ b/IdleSpectator/SpectatorMode.cs @@ -12,6 +12,12 @@ public static class SpectatorMode public static bool Active { get; private set; } + /// + /// True when idle was frozen for Lore/browse without wiping StoryPlanner / registry. + /// Resume via (true) restores that session instead of a full clear enable. + /// + public static bool BrowsePaused { get; private set; } + private static bool _forceFileChecked; public static void Toggle() @@ -19,7 +25,11 @@ public static class SpectatorMode SetActive(!Active); } - public static void SetActive(bool active, bool quiet = false) + /// + /// When disabling: freeze story/crisis/ledger/registry for Lore browse (do not Clear). + /// Ignored when enabling - resume uses . + /// + public static void SetActive(bool active, bool quiet = false, bool browsePause = false) { if (active && !ModSettings.Enabled) { @@ -36,22 +46,45 @@ public static class SpectatorMode if (Active) { WatchCaption.ClearPausePin(); - InterestDirector.OnSpectatorEnabled(); + bool fromBrowse = BrowsePaused; + BrowsePaused = false; + if (fromBrowse) + { + InterestDirector.OnSpectatorBrowseResumed(); + } + else + { + InterestDirector.OnSpectatorEnabled(); + } + SpeciesDiscovery.OnSpectatorEnabled(); Chronicle.OnSpectatorEnabled(); ChronicleHud.OnIdleResumed(); FocusRelationshipArrows.OnSpectatorEnabled(); - LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)"); + LogService.LogInfo( + fromBrowse + ? "[IdleSpectator] Spectator resumed after Lore/browse pause" + : "[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)"); } else { - InterestDirector.OnSpectatorDisabled(); + if (browsePause) + { + BrowsePaused = true; + InterestDirector.OnSpectatorBrowsePaused(); + } + else + { + BrowsePaused = false; + InterestDirector.OnSpectatorDisabled(); + } + SpeciesDiscovery.OnSpectatorDisabled(); FocusRelationshipArrows.OnSpectatorDisabled(); CameraDirector.ClearFollow(); if (quiet) { - // Manual-input pause: leave dossier up so ShowStatusBanner can reuse it. + // Manual-input / Lore pause: leave dossier up so ShowStatusBanner can reuse it. StateProbe.Reset(); } else diff --git a/IdleSpectator/Story/CharacterLedger.cs b/IdleSpectator/Story/CharacterLedger.cs deleted file mode 100644 index e35a8c6..0000000 --- a/IdleSpectator/Story/CharacterLedger.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; - -namespace IdleSpectator; - -/// -/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density. -/// -public static class CharacterLedger -{ - private struct LedgerEntry - { - public long UnitId; - public float Heat; - public float TouchedAt; - } - - private static readonly List Entries = new List(20); - private static readonly List ScratchIdx = new List(8); - - public static void Clear() - { - Entries.Clear(); - } - - public static int Count => Entries.Count; - - public static bool IsOnLedger(long unitId) - { - if (unitId == 0) - { - return false; - } - - for (int i = 0; i < Entries.Count; i++) - { - if (Entries[i].UnitId == unitId) - { - return true; - } - } - - return false; - } - - public static float Heat(long unitId) - { - if (unitId == 0) - { - return 0f; - } - - for (int i = 0; i < Entries.Count; i++) - { - if (Entries[i].UnitId == unitId) - { - return Entries[i].Heat; - } - } - - return 0f; - } - - public static float ScoreBonus(InterestCandidate c) - { - if (c == null) - { - return 0f; - } - - StoryWeights s = InterestScoringConfig.Story; - float h = Heat(c.SubjectId); - if (h <= 0f && c.RelatedId != 0) - { - h = Heat(c.RelatedId) * 0.5f; - } - - return h * s.ledgerWeight; - } - - public static void Touch(long unitId, float heatAdd = 1f) - { - if (unitId == 0) - { - return; - } - - float now = Time.unscaledTime; - for (int i = 0; i < Entries.Count; i++) - { - if (Entries[i].UnitId != unitId) - { - continue; - } - - LedgerEntry e = Entries[i]; - e.Heat = Mathf.Min(8f, e.Heat + heatAdd); - e.TouchedAt = now; - Entries[i] = e; - return; - } - - EnsureCapacityForNew(unitId); - Entries.Add(new LedgerEntry - { - UnitId = unitId, - Heat = Mathf.Clamp(heatAdd, 0.5f, 8f), - TouchedAt = now - }); - } - - public static void NoteFeatured(InterestCandidate scene) - { - if (scene == null) - { - return; - } - - float add = 1f; - if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit))) - { - add += 1.5f; - } - - if (InterestScoring.IsNotable(scene.FollowUnit)) - { - add += 0.75f; - } - - if (scene.SubjectId != 0) - { - Touch(scene.SubjectId, add); - float density = Chronicle.RecentHistoryCount( - scene.SubjectId, - InterestScoringConfig.Story.historyDensityWindowSeconds); - if (density > 0) - { - Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f)); - } - } - - if (scene.RelatedId != 0) - { - Touch(scene.RelatedId, add * 0.6f); - } - - float causal = CausalHeat.Get(scene.SubjectId); - if (causal > 0.5f) - { - Touch(scene.SubjectId, Mathf.Min(1.5f, causal)); - } - } - - /// - /// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough - /// to monopolize the next few picks for the same village cast. - /// - public static void CoolAfterSoftLifeBeat(InterestCandidate scene) - { - if (scene == null || !InterestVariety.IsSoftLifeChapter(scene)) - { - return; - } - - StoryWeights s = InterestScoringConfig.Story; - float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f; - float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f; - Bleed(scene.SubjectId, subjectMul); - if (scene.RelatedId != 0) - { - Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f)); - } - - // Mild cool across the rest of the ledger so harness-featured villagers fade. - for (int i = 0; i < Entries.Count; i++) - { - long id = Entries[i].UnitId; - if (id == 0 || id == scene.SubjectId || id == scene.RelatedId) - { - continue; - } - - Bleed(id, villageMul); - } - } - - public static void Bleed(long unitId, float multiplyHeat) - { - if (unitId == 0 || multiplyHeat >= 0.999f) - { - return; - } - - multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f); - for (int i = 0; i < Entries.Count; i++) - { - if (Entries[i].UnitId != unitId) - { - continue; - } - - LedgerEntry e = Entries[i]; - e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat); - Entries[i] = e; - return; - } - } - - public static void Enumerate(List into) - { - if (into == null) - { - return; - } - - into.Clear(); - for (int i = 0; i < Entries.Count; i++) - { - into.Add(Entries[i].UnitId); - } - } - - public static Actor PreferLedgerUnit(IEnumerable candidates) - { - if (candidates == null) - { - return null; - } - - Actor best = null; - float bestHeat = -1f; - foreach (Actor a in candidates) - { - if (a == null || !a.isAlive()) - { - continue; - } - - long id = EventFeedUtil.SafeId(a); - float h = Heat(id); - if (h > bestHeat) - { - bestHeat = h; - best = a; - } - } - - return bestHeat > 0f ? best : null; - } - - private static void EnsureCapacityForNew(long incomingId) - { - StoryWeights s = InterestScoringConfig.Story; - int cap = s.ledgerCap > 0 ? s.ledgerCap : 16; - if (Entries.Count < cap) - { - return; - } - - // Never evict current arc cast. - ScratchIdx.Clear(); - int worst = -1; - float worstHeat = float.MaxValue; - for (int i = 0; i < Entries.Count; i++) - { - long id = Entries[i].UnitId; - if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id)) - { - continue; - } - - if (id == incomingId) - { - continue; - } - - float h = Entries[i].Heat; - if (h < worstHeat) - { - worstHeat = h; - worst = i; - } - } - - if (worst >= 0) - { - Entries.RemoveAt(worst); - } - else if (Entries.Count > 0) - { - // All protected - drop oldest non-incoming. - Entries.RemoveAt(0); - } - } -} diff --git a/IdleSpectator/Story/LifeSagaOverview.cs b/IdleSpectator/Story/LifeSagaOverview.cs new file mode 100644 index 0000000..09aff77 --- /dev/null +++ b/IdleSpectator/Story/LifeSagaOverview.cs @@ -0,0 +1,504 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Multi-line saga hover card: Title / Why / Circle / Chapters. +/// No trait descs, no Chronicle dump, no planner jargon. +/// +public static class LifeSagaOverview +{ + private const int EpithetMinScore = 80; + + public static string Build(LifeSagaSlot slot) + { + if (slot == null || slot.UnitId == 0) + { + return ""; + } + + Actor actor = slot.Resolve(); + var sb = new StringBuilder(256); + string title = BuildTitle(slot, actor); + if (!string.IsNullOrEmpty(title)) + { + sb.Append(title); + } + + string why = BuildWhy(slot, actor); + AppendLine(sb, why); + + string circle = BuildCircle(actor); + AppendLine(sb, circle); + + string chapters = BuildChapters(slot); + AppendLine(sb, chapters); + + return sb.ToString().TrimEnd(); + } + + private static void AppendLine(StringBuilder sb, string line) + { + if (string.IsNullOrEmpty(line)) + { + return; + } + + if (sb.Length > 0) + { + sb.Append('\n'); + } + + sb.Append(line); + } + + private static string BuildTitle(LifeSagaSlot slot, Actor actor) + { + string name = !string.IsNullOrEmpty(slot.DisplayName) + ? slot.DisplayName + : "Someone"; + string epithet = TryEpithetName(actor); + string role = BuildRole(slot, actor); + bool starred = slot.Prefer || slot.GameFavorite; + + var sb = new StringBuilder(64); + if (starred) + { + sb.Append("★ "); + } + + sb.Append(name); + if (!string.IsNullOrEmpty(epithet)) + { + sb.Append(" the ").Append(epithet); + } + + if (!string.IsNullOrEmpty(role)) + { + sb.Append(" · ").Append(role); + } + + return sb.ToString(); + } + + private static string BuildRole(LifeSagaSlot slot, Actor actor) + { + if (slot.IsKing) + { + string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor)); + return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom; + } + + if (slot.IsAlpha) + { + return "Alpha of the pack"; + } + + if (slot.IsLeader) + { + string city = FirstNonEmpty(slot.CityKey, CityName(actor)); + return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city; + } + + string species = SpeciesLabel(slot, actor); + string kingdomFallback = FirstNonEmpty(slot.KingdomKey, KingdomName(actor)); + if (!string.IsNullOrEmpty(species) && !string.IsNullOrEmpty(kingdomFallback)) + { + return species + " of " + kingdomFallback; + } + + return FirstNonEmpty(species, kingdomFallback); + } + + private static string BuildWhy(LifeSagaSlot slot, Actor actor) + { + // Title already carries role / favorite star - Why adds standing/event only. + var parts = new List(3); + if (slot.Kills >= 10) + { + parts.Add(slot.Kills + " kills"); + } + + if (slot.Renown >= 80f) + { + parts.Add("renown " + Mathf.RoundToInt(slot.Renown)); + } + + if (IsAtWar(actor)) + { + string side = FirstNonEmpty(slot.KingdomKey, KingdomName(actor), SpeciesLabel(slot, actor)); + if (!string.IsNullOrEmpty(side)) + { + parts.Add("leading " + side + " in war"); + } + else + { + parts.Add("at war"); + } + } + else if (slot.IsKing && parts.Count == 0) + { + // Newly crowned with no kill/war signal - omit rather than reprint King. + } + + if (parts.Count == 0) + { + return ""; + } + + return string.Join(" · ", parts); + } + + private static string BuildCircle(Actor actor) + { + if (actor == null) + { + return ""; + } + + var parts = new List(3); + Actor lover = ActorRelation.GetLover(actor); + if (lover != null) + { + parts.Add("Lover " + SafeName(lover)); + } + + Actor friend = ActorRelation.GetBestFriend(actor); + if (friend != null && friend != lover) + { + parts.Add("Friend " + SafeName(friend)); + } + + foreach (Actor child in ActorRelation.EnumerateChildren(actor, max: 1)) + { + if (child != null && child != lover && child != friend) + { + parts.Add("Child " + SafeName(child)); + break; + } + } + + if (parts.Count == 0) + { + return ""; + } + + return string.Join(" · ", parts); + } + + private static string BuildChapters(LifeSagaSlot slot) + { + if (slot.Chapters == null || slot.Chapters.Count == 0) + { + return ""; + } + + var sb = new StringBuilder(128); + int n = Mathf.Min(LifeSagaRoster.MaxChapters, slot.Chapters.Count); + for (int i = 0; i < n; i++) + { + string line = slot.Chapters[i]; + if (string.IsNullOrEmpty(line)) + { + continue; + } + + if (sb.Length > 0) + { + sb.Append('\n'); + } + + sb.Append("· ").Append(line); + } + + return sb.ToString(); + } + + public static float StandoutTraitScore(Actor actor) + { + ActorTrait top = PickTopTrait(actor, out int score); + if (top == null) + { + return 0f; + } + + return Mathf.Max(0, score); + } + + private static string TryEpithetName(Actor actor) + { + ActorTrait top = PickTopTrait(actor, out int score); + if (top == null || score < EpithetMinScore) + { + return ""; + } + + return TraitDisplayName(top); + } + + private static ActorTrait PickTopTrait(Actor actor, out int bestScore) + { + bestScore = int.MinValue; + if (actor == null) + { + return null; + } + + try + { + if (!actor.hasTraits()) + { + return null; + } + + ActorTrait best = null; + foreach (ActorTrait trait in actor.getTraits()) + { + if (trait == null || string.IsNullOrEmpty(trait.id)) + { + continue; + } + + if (IsDefaultTraitForActor(trait, actor)) + { + continue; + } + + int score = TraitInterestScore(trait, actor); + if (score > bestScore) + { + bestScore = score; + best = trait; + } + } + + return best; + } + catch + { + return null; + } + } + + private static int TraitInterestScore(ActorTrait trait, Actor actor) + { + if (trait == null) + { + return int.MinValue; + } + + int score = 0; + try + { + score += (int)trait.rarity * 100; + } + catch + { + // ignore + } + + try + { + if (trait.type == TraitType.Negative) + { + score += 25; + } + else if (trait.type == TraitType.Positive) + { + score += 15; + } + } + catch + { + // ignore + } + + if (!IsDefaultTraitForActor(trait, actor)) + { + score += 50; + } + else + { + score -= 40; + } + + try + { + if (trait.rate_birth > 0 && trait.rate_birth <= 5) + { + score += 20; + } + else if (trait.rate_birth >= 40) + { + score -= 15; + } + } + catch + { + // ignore + } + + string id = trait.id ?? ""; + if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0 + || id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0) + { + score += 35; + } + + return score; + } + + private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor) + { + try + { + if (trait == null || actor == null || actor.asset == null + || trait.default_for_actor_assets == null) + { + return false; + } + + return trait.default_for_actor_assets.Contains(actor.asset); + } + catch + { + return false; + } + } + + private static string TraitDisplayName(ActorTrait trait) + { + try + { + string locale = trait.getLocaleID(); + if (!string.IsNullOrEmpty(locale)) + { + string localized = LocalizedTextManager.getText(locale); + if (!string.IsNullOrEmpty(localized) && localized != locale) + { + return localized; + } + } + } + catch + { + // fall through + } + + string id = trait.id ?? ""; + if (id.Length == 0) + { + return ""; + } + + return char.ToUpperInvariant(id[0]) + id.Substring(1).Replace('_', ' '); + } + + private static bool IsAtWar(Actor actor) + { + if (actor == null) + { + return false; + } + + try + { + Kingdom k = actor.kingdom; + if (k == null) + { + return false; + } + + return k.hasEnemies(); + } + catch + { + return false; + } + } + + private static string SpeciesLabel(LifeSagaSlot slot, Actor actor) + { + string id = FirstNonEmpty(slot?.SpeciesId, actor?.asset != null ? actor.asset.id : ""); + if (string.IsNullOrEmpty(id)) + { + return ""; + } + + if (id.Length == 1) + { + return id.ToUpperInvariant(); + } + + return char.ToUpperInvariant(id[0]) + id.Substring(1); + } + + private static string KingdomName(Actor actor) + { + try + { + return actor?.kingdom != null ? (actor.kingdom.name ?? "") : ""; + } + catch + { + return ""; + } + } + + private static string CityName(Actor actor) + { + try + { + return actor?.city != null ? (actor.city.name ?? "") : ""; + } + catch + { + return ""; + } + } + + private static string SafeName(Actor actor) + { + try + { + string n = actor.getName(); + if (!string.IsNullOrEmpty(n)) + { + return n; + } + } + catch + { + // ignore + } + + return actor?.asset != null ? actor.asset.id : "Someone"; + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) + { + return ""; + } + + for (int i = 0; i < values.Length; i++) + { + if (!string.IsNullOrEmpty(values[i])) + { + return values[i]; + } + } + + return ""; + } +} diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs new file mode 100644 index 0000000..3b65da2 --- /dev/null +++ b/IdleSpectator/Story/LifeSagaRail.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace IdleSpectator; + +/// +/// Dossier rail of life-saga MCs. Click toggles Prefer (soft favorite). Hover shows overview card. +/// +public static class LifeSagaRail +{ + private const int MaxSlots = LifeSagaRoster.Cap; + private const float SlotSize = 14f; + private const float Gap = 2f; + private const float HoverWidth = 300f; + private const float HoverLineHeight = 12f; + private const float HoverMaxHeight = 84f; + + private static readonly List SlotScratch = new List(MaxSlots); + private static readonly Slot[] Slots = new Slot[MaxSlots]; + private static GameObject _row; + private static RectTransform _rowRt; + private static Text _hoverText; + private static string _fingerprint = ""; + + /// Harness: tip text of the first visible rail slot after last Refresh. + public static string LastTipSample { get; private set; } = ""; + + private sealed class Slot + { + public GameObject Root; + public RectTransform Rt; + public Image Bg; + public Text Label; + public Button Button; + public long UnitId; + public string Tip = ""; + public LifeSagaRailHover Hover; + } + + public static bool Visible => + _row != null && _row.activeSelf; + + public static int LastShownCount { get; private set; } + + public static void EnsureBuilt(Transform parent) + { + if (_row != null || parent == null) + { + return; + } + + _row = new GameObject("LifeSagaRail", typeof(RectTransform)); + _row.transform.SetParent(parent, false); + _rowRt = _row.GetComponent(); + _rowRt.pivot = new Vector2(0f, 1f); + _rowRt.anchorMin = new Vector2(0f, 1f); + _rowRt.anchorMax = new Vector2(0f, 1f); + + for (int i = 0; i < MaxSlots; i++) + { + Slots[i] = BuildSlot(_row.transform, i); + } + + _hoverText = HudCanvas.MakeText(_row.transform, "Hover", "", 7); + _hoverText.color = HudTheme.Muted; + _hoverText.alignment = TextAnchor.UpperLeft; + _hoverText.horizontalOverflow = HorizontalWrapMode.Wrap; + _hoverText.verticalOverflow = VerticalWrapMode.Overflow; + _hoverText.raycastTarget = false; + _hoverText.gameObject.SetActive(false); + + _row.SetActive(false); + } + + public static void Clear() + { + _fingerprint = ""; + LastShownCount = 0; + LastTipSample = ""; + HideHover(); + if (_row != null) + { + _row.SetActive(false); + } + + for (int i = 0; i < Slots.Length; i++) + { + if (Slots[i]?.Root != null) + { + Slots[i].Root.SetActive(false); + Slots[i].UnitId = 0; + Slots[i].Tip = ""; + } + } + } + + public static void Refresh() + { + if (_row == null) + { + return; + } + + if (!SpectatorMode.Active && !SpectatorMode.BrowsePaused) + { + Clear(); + return; + } + + if (!ModSettings.ShowDossierCaption || WatchCaption.HasStatusBannerActive()) + { + Clear(); + return; + } + + LifeSagaRoster.Tick(Time.unscaledTime); + SlotScratch.Clear(); + LifeSagaRoster.CopySlots(SlotScratch); + string fp = Fingerprint(SlotScratch); + if (string.Equals(fp, _fingerprint, StringComparison.Ordinal) + && !LifeSagaRoster.Dirty) + { + return; + } + + LifeSagaRoster.Dirty = false; + _fingerprint = fp; + LastShownCount = 0; + LastTipSample = ""; + long watchId = EventFeedUtil.SafeId( + MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); + float x = 0f; + for (int i = 0; i < Slots.Length; i++) + { + Slot slot = Slots[i]; + if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0) + { + slot.Root.SetActive(false); + slot.UnitId = 0; + slot.Tip = ""; + continue; + } + + LifeSagaSlot saga = SlotScratch[i]; + bool watching = watchId != 0 && watchId == saga.UnitId; + bool lit = watching || saga.Prefer || saga.GameFavorite; + slot.UnitId = saga.UnitId; + slot.Label.text = GlyphFor(saga); + slot.Bg.color = lit + ? HudTheme.ValueOrange + : new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.55f); + slot.Label.color = lit ? Color.black : HudTheme.Body; + slot.Tip = LifeSagaOverview.Build(saga); + if (LastShownCount == 0) + { + LastTipSample = slot.Tip ?? ""; + } + + slot.Rt.anchoredPosition = new Vector2(x, 0f); + slot.Root.SetActive(true); + x += SlotSize + Gap; + LastShownCount++; + } + + _rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize); + _row.SetActive(LastShownCount > 0); + } + + /// Place rail under the spine; returns next y. + public static float Place(float y, float padX) + { + if (!Visible || _rowRt == null) + { + return y; + } + + _rowRt.anchoredPosition = new Vector2(padX, -y); + float next = y + SlotSize + 2f; + if (_hoverText != null && _hoverText.gameObject.activeSelf) + { + RectTransform ht = _hoverText.GetComponent(); + ht.pivot = new Vector2(0f, 1f); + ht.anchorMin = new Vector2(0f, 1f); + ht.anchorMax = new Vector2(0f, 1f); + ht.anchoredPosition = new Vector2(0f, -(SlotSize + 1f)); + float hoverH = EstimateHoverHeight(_hoverText.text); + ht.sizeDelta = new Vector2(HoverWidth, hoverH); + next += hoverH + 1f; + } + + return next; + } + + internal static void ShowHover(int index) + { + if (_hoverText == null || index < 0 || index >= Slots.Length) + { + return; + } + + string tip = Slots[index].Tip; + if (string.IsNullOrEmpty(tip) && Slots[index].UnitId != 0) + { + LifeSagaSlot s = LifeSagaRoster.Get(Slots[index].UnitId); + tip = LifeSagaOverview.Build(s); + Slots[index].Tip = tip; + } + + if (string.IsNullOrEmpty(tip)) + { + HideHover(); + return; + } + + _hoverText.text = tip; + _hoverText.gameObject.SetActive(true); + WatchCaption.RequestRelayout(); + } + + internal static void HideHover() + { + if (_hoverText != null) + { + _hoverText.gameObject.SetActive(false); + _hoverText.text = ""; + } + + WatchCaption.RequestRelayout(); + } + + private static float EstimateHoverHeight(string text) + { + if (string.IsNullOrEmpty(text)) + { + return HoverLineHeight; + } + + int lines = 1; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + lines++; + } + } + + return Mathf.Min(HoverMaxHeight, lines * HoverLineHeight + 2f); + } + + private static Slot BuildSlot(Transform parent, int index) + { + GameObject go = new GameObject("SagaRail" + index, typeof(RectTransform), typeof(Image), typeof(Button)); + go.transform.SetParent(parent, false); + RectTransform rt = go.GetComponent(); + rt.pivot = new Vector2(0f, 1f); + rt.anchorMin = new Vector2(0f, 1f); + rt.anchorMax = new Vector2(0f, 1f); + rt.sizeDelta = new Vector2(SlotSize, SlotSize); + + Image bg = go.GetComponent(); + bg.color = HudTheme.ToolFill; + bg.raycastTarget = true; + + Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 9); + label.alignment = TextAnchor.MiddleCenter; + label.resizeTextForBestFit = false; + RectTransform labelRt = label.GetComponent(); + labelRt.anchorMin = Vector2.zero; + labelRt.anchorMax = Vector2.one; + labelRt.offsetMin = Vector2.zero; + labelRt.offsetMax = Vector2.zero; + + Button button = go.GetComponent