diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 238011f..47a6404 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -816,7 +816,18 @@ public static class AgentHarness case "status_apply": { - Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + // Prefer explicit asset / related target so pair invincible does not only hit Follow. + Actor focus = null; + if (!string.IsNullOrEmpty(cmd.asset) && cmd.asset != "auto") + { + focus = ResolveUnit(cmd.asset); + } + + if (focus == null) + { + focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning"; float timer = 0f; if (!string.IsNullOrEmpty(cmd.label) @@ -2655,6 +2666,54 @@ public static class AgentHarness DoInterestWarSession(cmd); break; + case "interest_earthquake_session": + DoInterestEarthquakeSession(cmd); + break; + + case "interest_crisis_begin": + { + bool ok = StoryPlanner.HarnessBeginCrisisFromCurrent(); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + CrisisChapter crisis = StoryPlanner.Crisis; + Emit( + cmd, + ok, + detail: ok + ? $"crisis={crisis?.Kind} phase={crisis?.Phase}" + : "no_crisis_tip"); + break; + } + + case "interest_crisis_end": + { + bool ok = StoryPlanner.HarnessForceCrisisEpilogue(); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + CrisisChapter crisis = StoryPlanner.Crisis; + Emit( + cmd, + ok, + detail: ok + ? $"crisis={crisis?.Kind} phase={crisis?.Phase} epilogue=1" + : "no_crisis_signal"); + break; + } + case "interest_plot_session": DoInterestPlotSession(cmd); break; @@ -2741,6 +2800,10 @@ public static class AgentHarness DoWarEnsembleApply(cmd); break; + case "war_inject_theater_mass": + DoWarInjectTheaterMass(cmd); + break; + case "plot_ensemble_apply": DoPlotEnsembleApply(cmd); break; @@ -2765,10 +2828,18 @@ public static class AgentHarness DoCombatWipeStickySide(cmd); break; + case "combat_set_sticky_side_count": + DoCombatSetStickySideCount(cmd); + break; + case "combat_swap_attack_target": DoCombatSwapAttackTarget(cmd); break; + case "combat_kill_related": + DoCombatKillRelated(cmd); + break; + case "combat_probe_fighters": DoCombatProbeFighters(cmd); break; @@ -2942,6 +3013,96 @@ public static class AgentHarness Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}"); break; + case "combat_disengage_pair": + { + // Clear fight signals without force-cold - proves natural CombatStillActive release. + InterestCandidate scene = InterestDirector.CurrentCandidate; + Actor a = scene?.FollowUnit; + Actor b = scene?.RelatedUnit; + for (int i = 0; i < 4; i++) + { + ClearAttackTarget(a); + ClearAttackTarget(b); + TryClearCombatTask(a); + TryClearCombatTask(b); + } + + // Drop LastSeenAt so hysteresis cannot bridge a cleared scrap; live fighting + // still refreshes hot via IsCombatParticipant on the next tick. + if (scene != null) + { + scene.LastSeenAt = Time.unscaledTime - 60f; + } + + bool aFight = LiveEnsemble.IsCombatParticipant(a); + bool bFight = LiveEnsemble.IsCombatParticipant(b); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"disengaged a={SafeName(a)}:{aFight} b={SafeName(b)}:{bFight} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}"); + break; + } + + case "combat_spark_assets": + { + // Wire two species near the camera into a scrap without stamping the current scene. + try + { + string sideA = string.IsNullOrEmpty(cmd.asset) ? "bear" : cmd.asset.Trim(); + string sideB = string.IsNullOrEmpty(cmd.value) ? "crocodile" : cmd.value.Trim(); + Vector3 origin = CameraPos(); + Actor a = WorldActivityScanner.FindNearestAliveUnit(origin, 80f, sideA); + Actor b = WorldActivityScanner.FindNearestAliveUnit(origin, 80f, sideB); + if (b != null && a != null && b == a) + { + b = FindNearestAliveAsset(sideB, preferUnfocused: true, otherThan: a); + } + + int wired = 0; + if (TrySetAttackTarget(a, b)) + { + wired++; + } + + if (TrySetAttackTarget(b, a)) + { + wired++; + } + + int stickyA = 0; + int stickyB = 0; + InterestCandidate cur = InterestDirector.CurrentCandidate; + if (cur != null) + { + stickyA = cur.CombatSideACount; + stickyB = cur.CombatSideBCount; + } + + bool ok = wired > 0; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"sparked={wired} a={SafeName(a)}:{sideA} b={SafeName(b)}:{sideB} sceneSticky={stickyA}:{stickyB}"); + } + catch (Exception ex) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "exception: " + ex.GetType().Name + ": " + ex.Message); + } + + break; + } + case "interest_mark_combat_cold": { // Drop attack/combat signals so live path cannot refresh hot; force-cold gates the hold. @@ -4942,6 +5103,70 @@ public static class AgentHarness detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' sides={keyA}/{keyB}"); } + /// + /// Force an EarthquakeActive disaster session so crisis chapters can gate without live quakes. + /// + private static void DoInterestEarthquakeSession(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); + if (follow == null) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + if (follow == null || !follow.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no follow unit for interest_earthquake_session"); + return; + } + + string label = EventReason.Earthquake(follow); + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "earthquake" : cmd.expect; + InterestCandidate c = InterestFeeds.RegisterHarness( + follow, + label, + keySuffix, + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.EarthquakeActive, + forceActive: true, + eventStrength: 95f, + characterSignificance: 12f, + maxWatch: 45f, + ttlSeconds: 45f, + related: null, + resumable: true); + if (c != null) + { + c.Category = "Disaster"; + c.AssetId = "earthquake"; + c.ClearCombatSticky(); + InterestScoring.ScoreCheap(c); + } + + bool ok = InterestDirector.HarnessForceSession(c); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}'"); + } + /// /// Rewrite sticky war counts on the current WarFront scene. /// value = "aCount:bCount" (default 12:8). @@ -5036,6 +5261,120 @@ public static class AgentHarness detail: $"tip='{CameraDirector.LastWatchLabel}' counts={countA}:{countB} participants={scene.ParticipantCount}"); } + /// + /// Register a hot CombatActive Mass tip on the current WarFront kingdom pair + /// (pending peer) so war_theater_hold can be gated. + /// + private static void DoWarInjectTheaterMass(HarnessCommand cmd) + { + InterestCandidate war = InterestDirector.CurrentCandidate; + if (war == null + || war.Completion != InterestCompletionKind.WarFront + || war.Sticky == null + || !war.Sticky.HasOpposingSides) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_war_theater"); + return; + } + + // Copy the war sticky keys verbatim so SharesKingdomTheater cannot miss on aliases. + string keyA = war.CombatSideAKey; + string keyB = war.CombatSideBKey; + string dispA = !string.IsNullOrEmpty(war.CombatSideADisplay) + ? war.CombatSideADisplay + : LiveEnsemble.KingdomDisplay(keyA); + string dispB = !string.IsNullOrEmpty(war.CombatSideBDisplay) + ? war.CombatSideBDisplay + : LiveEnsemble.KingdomDisplay(keyB); + if (string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "empty_war_keys"); + return; + } + + Actor focus = war.FollowUnit; + Actor related = war.RelatedUnit; + if (focus == null || !focus.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_war_follow"); + return; + } + + int countA = 8; + int countB = 6; + string raw = (cmd.value ?? "").Trim(); + int colon = raw.IndexOf(':'); + if (colon > 0) + { + int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out countA); + int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out countB); + } + + countA = Math.Max(1, countA); + countB = Math.Max(1, countB); + var ensemble = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Frame = EnsembleFrame.KingdomVsKingdom, + Scale = EnsembleScale.Mass, + ParticipantCount = countA + countB, + Focus = focus, + Related = related, + SideA = new EnsembleSide + { + Key = keyA, + Display = dispA, + KingdomDisplay = dispA, + Count = countA, + Best = focus + }, + SideB = new EnsembleSide + { + Key = keyB, + Display = dispB, + KingdomDisplay = dispB, + Count = countB, + Best = related + } + }; + string label = EventReason.Combat(ensemble); + InterestCandidate mass = InterestFeeds.RegisterHarness( + focus, + label, + "war_theater_mass", + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.CombatActive, + forceActive: false, + eventStrength: 140f, + characterSignificance: 25f, + maxWatch: 40f, + ttlSeconds: 40f, + related: related, + resumable: true); + if (mass == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "register_failed"); + return; + } + + mass.Category = "Combat"; + mass.AssetId = "live_battle"; + mass.ClearCombatSticky(); + StickyScoreboard.TryLockFromEnsemble(mass.Sticky, ensemble); + mass.ParticipantCount = ensemble.ParticipantCount; + mass.Label = label; + InterestScoring.ScoreCheap(mass); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"pending mass tip='{label}' theater={keyA}/{keyB} evt={mass.EventStrength:0.#} warTip='{CameraDirector.LastWatchLabel}'"); + } + /// /// Force a sticky StatusOutbreak session: Outbreak - Status (n). /// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick. @@ -6231,6 +6570,70 @@ public static class AgentHarness detail: $"parked={SafeName(bystander)} fighting={fighting} tip='{CameraDirector.LastWatchLabel}'"); } + private static Actor FindNearestAliveAsset( + string assetId, + bool preferUnfocused = false, + Actor otherThan = null) + { + if (string.IsNullOrEmpty(assetId)) + { + return null; + } + + try + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + Vector3 origin = CameraPos(); + Actor best = null; + float bestDist = float.MaxValue; + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || actor == otherThan || actor.asset == null) + { + continue; + } + + try + { + if (!actor.isAlive()) + { + continue; + } + + if (!string.Equals(actor.asset.id, assetId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (preferUnfocused && focus != null && actor == focus) + { + continue; + } + + Vector3 p = actor.current_position; + float dx = p.x - origin.x; + float dy = p.y - origin.y; + float d2 = dx * dx + dy * dy; + if (d2 < bestDist) + { + bestDist = d2; + best = actor; + } + } + catch + { + // skip unstable actor + } + } + + return best; + } + catch + { + return null; + } + } + private static void ClearAttackTarget(Actor unit) { if (unit == null) @@ -6695,6 +7098,98 @@ public static class AgentHarness detail: $"wiped={(wipeB ? "b" : "a")} sides={sticky.SideACount}/{sticky.SideBCount} peak={sticky.PeakParticipants} tip='{scene.Label}'"); } + /// + /// Harness: set one sticky camp's live count without clearing mop-up + /// (value = a:N or b:N). Used to soak 0↔1 pack↔vs thrash. + /// + private static void DoCombatSetStickySideCount(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no sticky opposing combat sides"); + return; + } + + string raw = (cmd.value ?? "b:1").Trim().ToLowerInvariant(); + string which = "b"; + int count = 1; + int colon = raw.IndexOf(':'); + if (colon > 0) + { + which = raw.Substring(0, colon).Trim(); + int.TryParse(raw.Substring(colon + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out count); + } + else if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int onlyCount)) + { + count = onlyCount; + } + + count = Math.Max(0, count); + LiveSceneStickyState sticky = scene.Sticky; + bool setB = which != "a" && which != "sidea" && which != "0"; + if (setB) + { + sticky.SideBCount = count; + if (count <= 0) + { + sticky.SideBIds.Clear(); + } + } + else + { + sticky.SideACount = count; + if (count <= 0) + { + sticky.SideAIds.Clear(); + } + } + + scene.CombatSideACount = sticky.SideACount; + scene.CombatSideBCount = sticky.SideBCount; + scene.ParticipantCount = sticky.TotalCount; + StickyScoreboard.StabilizeScale( + sticky, + new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Frame = sticky.Frame, + ParticipantCount = Math.Max(1, sticky.TotalCount), + SideA = new EnsembleSide + { + Key = sticky.SideAKey, + Display = sticky.SideADisplay, + KingdomDisplay = sticky.SideAKingdom, + Count = sticky.SideACount + }, + SideB = new EnsembleSide + { + Key = sticky.SideBKey, + Display = sticky.SideBDisplay, + KingdomDisplay = sticky.SideBKingdom, + Count = sticky.SideBCount + }, + Focus = scene.FollowUnit, + Related = scene.RelatedUnit + }, + Time.unscaledTime); + + string label = StickyScoreboard.FormatReason(scene, scene.FollowUnit, scene.RelatedUnit); + if (!string.IsNullOrEmpty(label)) + { + scene.Label = label; + CameraDirector.Watch(scene.ToInterestEvent()); + } + + _cmdOk++; + Emit( + cmd, + ok: true, + detail: + $"set={(setB ? "b" : "a")}:{count} sides={sticky.SideACount}/{sticky.SideBCount} mop={sticky.MopUpActive} tip='{scene.Label}'"); + } + /// Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus. private static void DoCombatProbeFighters(HarnessCommand cmd) { @@ -6739,10 +7234,10 @@ public static class AgentHarness } /// - /// Spawn a distractor and point the combat Follow's attack_target at them while the - /// locked pair partner stays alive. Maintain / director must not flip the Duel tip. + /// Kill the locked pair partner while leaving Follow fighting. First-partner lock must + /// not chain-adopt the next attack_target into a new Duel tip. /// - private static void DoCombatSwapAttackTarget(HarnessCommand cmd) + private static void DoCombatKillRelated(HarnessCommand cmd) { InterestCandidate scene = InterestDirector.CurrentCandidate; if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) @@ -6752,49 +7247,89 @@ public static class AgentHarness return; } - Actor focus = scene.FollowUnit; Actor partner = scene.RelatedUnit; - if (focus == null || !focus.isAlive()) - { - focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; - } - - if (focus == null || !focus.isAlive()) - { - _cmdFail++; - Emit(cmd, ok: false, detail: "no_focus"); - return; - } - if (partner == null || !partner.isAlive()) { - partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); + partner = EventFeedUtil.FindUnitById(scene.PairPartnerId); } - string distractorAsset = !string.IsNullOrEmpty(cmd.asset) - ? cmd.asset.Trim() - : (partner?.asset != null ? partner.asset.id : "wolf"); - Actor distractor = SpawnAssetNear(distractorAsset, focus.current_position, 3.5f); - if (distractor == null || !distractor.isAlive()) + if (partner == null) { _cmdFail++; - Emit(cmd, ok: false, detail: "spawn_failed"); + Emit(cmd, ok: false, detail: "no_partner"); return; } - // Keep the locked partner alive so the hold must prefer them over the new target. - if (partner != null && partner.isAlive()) + long partnerId = EventFeedUtil.SafeId(partner); + string partnerName = SafeName(partner); + try { - StatusGameApi.TryAddStatus(partner, "invincible", 12f); + if (partner.isAlive()) + { + partner.die(true, AttackType.Divine); + } + } + catch (Exception ex) + { + _cmdFail++; + Emit(cmd, ok: false, detail: $"die_failed={ex.Message}"); + return; } - StatusGameApi.TryAddStatus(distractor, "invincible", 8f); - bool wired = TrySetAttackTarget(focus, distractor); - TrySetAttackTarget(distractor, focus); - // Publish the noisy peer pair the way live activity does (new combat:pair key). - InterestFeeds.OnActivityNote(focus, "fighting", hot: true); + bool dead = false; + try + { + dead = partner == null || !partner.isAlive(); + } + catch + { + dead = true; + } - if (wired) + // Keep the durable partner id so maintain proves we do not chain-replace. + if (partnerId != 0) + { + scene.PairPartnerId = partnerId; + } + + // Drop dead RelatedUnit refs so later maintain / swap cannot touch destroyed actors. + scene.RelatedUnit = null; + scene.RelatedId = partnerId; + Actor focus = scene.FollowUnit; + if (focus == null && MoveCamera.hasFocusUnit()) + { + focus = MoveCamera._focus_unit; + } + + ClearAttackTarget(focus); + + // Thin Duel tips only: immediately leave NamedPair wording naming the corpse. + // Collective Mass/Battle tips stay for maintain / sticky framing. + string priorTip = scene.Label ?? ""; + bool thinDuel = priorTip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + if (dead && focus != null && thinDuel) + { + try + { + if (focus.isAlive()) + { + string holdTip = EventReason.Fight(focus, null); + if (!string.IsNullOrEmpty(holdTip)) + { + scene.Label = holdTip; + scene.FollowUnit = focus; + scene.SubjectId = EventFeedUtil.SafeId(focus); + CameraDirector.Watch(scene.ToInterestEvent()); + } + } + } + catch + { + // maintain path still owns the hold + } + } + + if (dead) { _cmdOk++; } @@ -6805,8 +7340,207 @@ public static class AgentHarness Emit( cmd, - wired, - detail: $"focus={SafeName(focus)} partner={SafeName(partner)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'"); + dead, + detail: $"partner={partnerName} id={partnerId} lock={scene.PairPartnerId} tip='{CameraDirector.LastWatchLabel}'"); + } + + /// + /// Spawn a distractor and point the combat Follow's attack_target at them while the + /// locked pair partner stays alive. Maintain / director must not flip the Duel tip. + /// + private static void DoCombatSwapAttackTarget(HarnessCommand cmd) + { + try + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_combat_scene"); + return; + } + + Actor focus = scene.FollowUnit; + if (focus == null) + { + focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + try + { + if (focus == null || !focus.isAlive()) + { + focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + } + catch + { + focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + bool focusOk = false; + try + { + focusOk = focus != null && focus.isAlive(); + } + catch + { + focusOk = false; + } + + if (!focusOk) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_focus"); + return; + } + + // Stale attack_target after partner death can NRE later Unity paths. + ClearAttackTarget(focus); + + Actor partner = null; + try + { + partner = scene.RelatedUnit; + if (partner != null && !partner.isAlive()) + { + partner = null; + } + } + catch + { + partner = null; + } + + if (partner == null && scene.PairPartnerId != 0) + { + partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); + } + + string distractorAsset = !string.IsNullOrEmpty(cmd.asset) + ? cmd.asset.Trim() + : "wolf"; + if (string.IsNullOrEmpty(cmd.asset) && partner != null) + { + try + { + if (partner.asset != null && !string.IsNullOrEmpty(partner.asset.id)) + { + distractorAsset = partner.asset.id; + } + } + catch + { + distractorAsset = "wolf"; + } + } + + Vector3 near = default; + try + { + near = focus.current_position; + } + catch + { + _cmdFail++; + Emit(cmd, ok: false, detail: "focus_pos_failed"); + return; + } + + Actor distractor = SpawnAssetNear(distractorAsset, near, 3.5f); + if (distractor == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "spawn_failed"); + return; + } + + try + { + if (!distractor.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "spawn_dead"); + return; + } + } + catch + { + _cmdFail++; + Emit(cmd, ok: false, detail: "spawn_invalid"); + return; + } + + // Keep the locked partner alive so the hold must prefer them over the new target. + TryHarnessInvincible(partner, 12f); + TryHarnessInvincible(distractor, 8f); + bool wired = TrySetAttackTarget(focus, distractor); + TrySetAttackTarget(distractor, focus); + // Peer noise without requiring a living RelatedUnit (post partner-death). + try + { + InterestFeeds.OnActivityNote(focus, "fighting", hot: true); + } + catch (Exception ex) + { + // Wire alone still exercises the hold; do not fail the step on feed NRE. + Emit( + cmd, + wired, + detail: $"wired={wired} activity_note_err={ex.Message} focus={SafeName(focus)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'"); + if (wired) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + return; + } + + if (wired) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + wired, + detail: $"focus={SafeName(focus)} partner={SafeName(partner)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'"); + } + catch (Exception ex) + { + _cmdFail++; + Emit(cmd, ok: false, detail: $"swap_exception={ex.GetType().Name}:{ex.Message}"); + } + } + + private static void TryHarnessInvincible(Actor unit, float seconds) + { + if (unit == null) + { + return; + } + + try + { + if (!unit.isAlive()) + { + return; + } + + StatusGameApi.TryAddStatus(unit, "invincible", seconds); + } + catch + { + // Status API can NRE on edge actor states - wire alone is enough for holds. + } } private static bool TrySetAttackTarget(Actor unit, Actor foe) @@ -7027,6 +7761,25 @@ public static class AgentHarness detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}"; break; } + case "crisis_active": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = StoryPlanner.CrisisActive; + pass = have == want; + CrisisChapter crisis = StoryPlanner.Crisis; + detail = $"crisisActive={have} want={want} kind={crisis?.Kind} phase={crisis?.Phase}"; + break; + } + case "crisis_kind": + { + string want = (cmd.value ?? "").Trim(); + CrisisChapter crisis = StoryPlanner.Crisis; + string have = crisis != null ? crisis.Kind.ToString() : "None"; + pass = !string.IsNullOrEmpty(want) + && string.Equals(have, want, StringComparison.OrdinalIgnoreCase); + detail = $"crisisKind={have} want={want} active={StoryPlanner.CrisisActive}"; + break; + } case "story_spine": { string want = (cmd.value ?? "").Trim(); @@ -7181,6 +7934,16 @@ public static class AgentHarness detail = $"focus={SafeName(unit)} fighting={fighting} want={want}"; break; } + case "related_fighting": + { + Actor unit = InterestDirector.CurrentCandidate?.RelatedUnit; + bool fighting = LiveEnsemble.IsCombatParticipant(unit); + bool want = string.IsNullOrEmpty(cmd.value) + || !string.Equals(cmd.value, "false", StringComparison.OrdinalIgnoreCase); + pass = fighting == want; + detail = $"related={SafeName(unit)} fighting={fighting} want={want}"; + break; + } case "tip_matches_focus": { Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -7798,6 +8561,22 @@ public static class AgentHarness detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)} reason='{dossier?.ReasonLine}'"; break; } + case "caption_not_contains": + { + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + string caption = WatchCaption.LastCaptionText ?? ""; + string detailLine = WatchCaption.LastDetail ?? ""; + UnitDossier dossier = WatchCaption.Current; + string dossierDetail = dossier?.DetailLine ?? ""; + bool hit = (!string.IsNullOrEmpty(needle) + && (caption.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0 + || detailLine.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0 + || dossierDetail.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)); + pass = !hit; + detail = + $"needle='{needle}' hit={hit} caption='{caption.Replace("\n", " | ")}' detail='{detailLine}'"; + break; + } case "tooltip_library_ok": { pass = DossierAssetTips.RunTooltipInventoryAudit(HarnessDir(), out string tipDetail); @@ -8175,6 +8954,27 @@ public static class AgentHarness } } + // Mage / warlock creature drop powers must be camera Signal (soak: evil mages ignored). + string[] magePowers = + { + "evil_mage", "white_mage", "necromancer", "plague_doctor", "druid" + }; + int magePowerMissing = 0; + for (int i = 0; i < magePowers.Length; i++) + { + DiscreteEventEntry e = EventCatalog.Power.GetOrFallback(magePowers[i]); + lines.Add("powers\t" + magePowers[i] + "\t" + e.CreatesInterest + "\t" + + e.EventStrength.ToString("0.#") + "\tmage"); + if (!e.CreatesInterest) + { + magePowerMissing++; + } + else + { + signalCamera++; + } + } + // Genes / clan / language are all B (never own the camera). int geneCamera = 0; foreach (string id in EventCatalog.Gene.AuthoredIds) @@ -8233,6 +9033,7 @@ public static class AgentHarness // diagnostic dump only } + int wantSignal = spectacleSpells.Length + magePowers.Length; pass = dialBad == 0 && phenotypeCamera == 0 && geneCamera == 0 @@ -8240,14 +9041,16 @@ public static class AgentHarness && languageCamera == 0 && spellFxCamera == 0 && spellSpectacleMissing == 0 + && magePowerMissing == 0 && lawSignal > 0 && itemSignal > 0 - && signalCamera == spectacleSpells.Length; + && signalCamera == wantSignal; detail = $"dialBad={dialBad} phenotypeCamera={phenotypeCamera} geneCamera={geneCamera} " + $"clanCamera={clanCamera} languageCamera={languageCamera} " + $"spellFxCamera={spellFxCamera} spellSpectacleMissing={spellSpectacleMissing} " - + $"lawSignal={lawSignal} itemSignal={itemSignal} spectacleOk={signalCamera}"; + + $"magePowerMissing={magePowerMissing} " + + $"lawSignal={lawSignal} itemSignal={itemSignal} spectacleOk={signalCamera}/{wantSignal}"; break; } case "dossier_not_contains": diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs index 7c4633b..781f97b 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs @@ -149,11 +149,18 @@ public static partial class EventCatalog || s.Contains("equipment_rain") || s.EndsWith("_brush", StringComparison.Ordinal) || s.Contains("draw_") - || s.Contains("paint")) + || s.Contains("paint") + || s.StartsWith("boat_", StringComparison.Ordinal)) { return false; } + // Creature drop powers that match live spectacle actors (evil_mage, necromancer, …). + if (WorldActivityScanner.IsSpectacleAssetId(s)) + { + return true; + } + return s.Contains("meteor") || s.Contains("earthquake") || s.Contains("tornado") @@ -182,7 +189,10 @@ public static partial class EventCatalog || s.Contains("ash") || s.Contains("golden_brain") || s.Contains("corrupted_brain") - || s.Contains("infection"); + || s.Contains("infection") + || s.Contains("mage") + || s.Contains("necromancer") + || s.Contains("druid"); } private static void Ensure() diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs b/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs index 49a9a01..3ebf11c 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs @@ -17,15 +17,22 @@ public sealed class WorldLogEventEntry public string MakeLabel(string special1, string special2) { - string a = special1 ?? ""; - string b = special2 ?? ""; + string a = (special1 ?? "").Trim(); + string b = (special2 ?? "").Trim(); string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate; - return template - .Replace("{id}", Id ?? "") + string displayId = EventReason.HumanizeId(Id); + if (string.IsNullOrEmpty(displayId)) + { + displayId = (Id ?? "").Trim(); + } + + string label = template + .Replace("{id}", displayId) .Replace("{a}", a) .Replace("{b}", b) .Replace("{special1}", a) .Replace("{special2}", b); + return CollapseSparseLabel(label, displayId); } public string MakeLabel(WorldLogMessage message) @@ -35,7 +42,79 @@ public sealed class WorldLogEventEntry return MakeLabel("", ""); } - return MakeLabel(message.special1, message.special2); + string a = message.special1 ?? ""; + string b = message.special2 ?? ""; + // Many disaster WorldLogs leave special1 empty - prefer the follow unit name, + // else CollapseSparseLabel drops hanging "Tornado: " tails. + if (string.IsNullOrWhiteSpace(a) && message.unit != null) + { + a = EventFeedUtil.SafeName(message.unit) ?? ""; + } + + return MakeLabel(a, b); + } + + /// + /// Drop empty-slot leftovers ("Tornado: ", "Event: ") so tips never show a bare colon. + /// + public static string CollapseSparseLabel(string label, string displayIdFallback) + { + if (string.IsNullOrWhiteSpace(label)) + { + return string.IsNullOrEmpty(displayIdFallback) ? "Event" : displayIdFallback; + } + + string s = label.Trim(); + while (s.IndexOf(" ", StringComparison.Ordinal) >= 0) + { + s = s.Replace(" ", " "); + } + + // Trailing separator after an empty {a}/{b}. + while (s.Length > 0) + { + char last = s[s.Length - 1]; + if (last == ':' || last == '-' || last == '·' || last == '|') + { + s = s.Substring(0, s.Length - 1).TrimEnd(); + continue; + } + + break; + } + + // Leading separator when {a} was first and empty. + while (s.Length > 0) + { + char first = s[0]; + if (first == ':' || first == '-' || first == '·' || first == '|') + { + s = s.Substring(1).TrimStart(); + continue; + } + + break; + } + + if (string.IsNullOrWhiteSpace(s)) + { + return string.IsNullOrEmpty(displayIdFallback) ? "Event" : displayIdFallback; + } + + return s; + } + + /// True when a rendered tip would look empty or end with a hanging colon. + public static bool IsHangingLabel(string label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return true; + } + + string s = label.TrimEnd(); + return s.EndsWith(":", StringComparison.Ordinal) + || s.EndsWith(": ", StringComparison.Ordinal); } } diff --git a/IdleSpectator/Events/EventCatalog.cs b/IdleSpectator/Events/EventCatalog.cs index 4060897..0df0c70 100644 --- a/IdleSpectator/Events/EventCatalog.cs +++ b/IdleSpectator/Events/EventCatalog.cs @@ -114,6 +114,26 @@ public static partial class EventCatalog public static string KeyFor(Actor follow, Actor related) { + // Spectacle 1vN scraps share one scene so attack_target hops merge instead of + // spawning combat:pair:owner:victimN keys that thrash Duel tips. + if (follow != null && WorldActivityScanner.IsSpectaclePublic(follow)) + { + long sid = EventFeedUtil.SafeId(follow); + if (sid != 0) + { + return "combat:spectacle:" + sid; + } + } + + if (related != null && WorldActivityScanner.IsSpectaclePublic(related)) + { + long sid = EventFeedUtil.SafeId(related); + if (sid != 0) + { + return "combat:spectacle:" + sid; + } + } + long a = EventFeedUtil.SafeId(follow); long b = EventFeedUtil.SafeId(related); return b != 0 ? PairKey(a, b) : Key(a); diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index ba7095b..85c5fb9 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -52,7 +52,67 @@ public static class EventReason return false; } - return string.Equals(prevKey, nextKey, StringComparison.Ordinal); + if (string.Equals(prevKey, nextKey, StringComparison.Ordinal)) + { + return true; + } + + // Pack mop-up ↔ thin vs remnant: Mass - Igguorn ↔ Battle - Igguorn vs The Godo. + return SameCombatTheaterCamps(prevKey, nextKey); + } + + private static bool SameCombatTheaterCamps(string prevKey, string nextKey) + { + if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b)) + { + return CampEq(pack, a) || CampEq(pack, b); + } + + if (TryPackSide(nextKey, out pack) && TryVsSides(prevKey, out a, out b)) + { + return CampEq(pack, a) || CampEq(pack, b); + } + + return false; + } + + private static bool TryPackSide(string key, out string pack) + { + pack = ""; + if (string.IsNullOrEmpty(key) || !key.StartsWith("pack:", StringComparison.Ordinal)) + { + return false; + } + + pack = key.Substring(5); + return !string.IsNullOrEmpty(pack); + } + + private static bool TryVsSides(string key, out string a, out string b) + { + a = ""; + b = ""; + if (string.IsNullOrEmpty(key) || key.StartsWith("pack:", StringComparison.Ordinal)) + { + return false; + } + + int bar = key.IndexOf('|'); + if (bar <= 0 || bar >= key.Length - 1) + { + return false; + } + + a = key.Substring(0, bar); + b = key.Substring(bar + 1); + return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b); + } + + private static bool CampEq(string a, string b) + { + return !string.IsNullOrEmpty(a) + && !string.IsNullOrEmpty(b) + && string.Equals(a, b, StringComparison.OrdinalIgnoreCase); } private static bool TryCombatCampKey(string label, out string key) @@ -71,7 +131,9 @@ public static class EventReason } string tier = t.Substring(0, dash).Trim(); - if (!tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase) + // Include Duel so A vs B ↔ B vs A is framing-only (no Watch / tip-order churn). + if (!tier.Equals("Duel", StringComparison.OrdinalIgnoreCase) + && !tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase) && !tier.Equals("Battle", StringComparison.OrdinalIgnoreCase) && !tier.Equals("Mass", StringComparison.OrdinalIgnoreCase)) { diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs index 2147a29..4893b5c 100644 --- a/IdleSpectator/Events/LiveEnsemble.cs +++ b/IdleSpectator/Events/LiveEnsemble.cs @@ -1492,6 +1492,13 @@ public sealed class LiveEnsemble return false; } + // Sleeping / stunned / frozen / lying units are not in a live scrap even if an + // attack_target pointer still lingers (soak + combat_focus cold after sleep). + if (IsCombatIncapacitated(actor)) + { + return false; + } + try { if (actor.has_attack_target) @@ -1509,6 +1516,49 @@ public sealed class LiveEnsemble } } + /// + /// True when game state says the actor cannot meaningfully fight right now. + /// Uses Actor APIs + live sleeping status - not an authored id deny list. + /// + public static bool IsCombatIncapacitated(Actor actor) + { + if (actor == null) + { + return true; + } + + // Parked only: sleep / freeze / unconscious. Do not treat stun or isLying as out of + // the scrap - those are normal combat FX and were collapsing Duels to "is fighting". + try + { + var ids = actor.getStatusesIds(); + if (ids != null) + { + foreach (string id in ids) + { + if (string.IsNullOrEmpty(id)) + { + continue; + } + + string s = id.ToLowerInvariant(); + if (s.IndexOf("sleep", StringComparison.Ordinal) >= 0 + || s.IndexOf("frozen", StringComparison.Ordinal) >= 0 + || s.IndexOf("uncon", StringComparison.Ordinal) >= 0) + { + return true; + } + } + } + } + catch + { + // ignore + } + + return false; + } + /// /// Sticky scoreboard roster: enroll matching side keys in radius, then count every /// tracked member that is still alive until the scene grace ends. diff --git a/IdleSpectator/Events/LiveSceneStickyState.cs b/IdleSpectator/Events/LiveSceneStickyState.cs index b15fce4..60d6e0e 100644 --- a/IdleSpectator/Events/LiveSceneStickyState.cs +++ b/IdleSpectator/Events/LiveSceneStickyState.cs @@ -18,6 +18,12 @@ public sealed class LiveSceneStickyState public bool HasPresentedScale; public EnsembleScale PresentedScale; public float ScaleChangeSince = -999f; + /// + /// After a camp hits 0, stay in mop-up until the thin side recovers to + /// (stops 0↔1 Mass↔Battle thrash). + /// + public bool MopUpActive; + public float MopUpSince = -999f; public string SideAKey = ""; public string SideADisplay = ""; @@ -54,6 +60,8 @@ public sealed class LiveSceneStickyState HasPresentedScale = false; PresentedScale = EnsembleScale.Pair; ScaleChangeSince = -999f; + MopUpActive = false; + MopUpSince = -999f; SideAKey = ""; SideADisplay = ""; SideAKingdom = ""; @@ -81,6 +89,8 @@ public sealed class LiveSceneStickyState other.HasPresentedScale = HasPresentedScale; other.PresentedScale = PresentedScale; other.ScaleChangeSince = ScaleChangeSince; + other.MopUpActive = MopUpActive; + other.MopUpSince = MopUpSince; other.SideAKey = SideAKey; other.SideADisplay = SideADisplay; other.SideAKingdom = SideAKingdom; diff --git a/IdleSpectator/Events/StickyScoreboard.cs b/IdleSpectator/Events/StickyScoreboard.cs index 3cfb260..aa36bae 100644 --- a/IdleSpectator/Events/StickyScoreboard.cs +++ b/IdleSpectator/Events/StickyScoreboard.cs @@ -11,6 +11,8 @@ namespace IdleSpectator; public static class StickyScoreboard { public const float ScaleHoldSeconds = 2.5f; + /// Thin camp must reach this alive count to leave mop-up pack framing. + public const int WipeRecoverMin = 2; public const float WarTheaterRadius = 48f; public const float PlotTheaterRadius = 36f; public const float FamilyTheaterRadius = 28f; @@ -157,11 +159,15 @@ namespace IdleSpectator; sticky.PeakParticipants = n; } + UpdateMopUp(sticky, now); + EnsembleScale live = LiveEnsemble.ScaleForCount(n); - // Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up. + // Opposing camp wiped / mop-up: drop Peak Mass hold so tip tier matches living pack. bool wipedOpposing = sticky.HasOpposingSides && sticky.TotalCount > 0 - && (sticky.SideACount <= 0 || sticky.SideBCount <= 0); + && (sticky.MopUpActive + || sticky.SideACount <= 0 + || sticky.SideBCount <= 0); if (wipedOpposing) { sticky.HasPresentedScale = true; @@ -465,6 +471,8 @@ namespace IdleSpectator; sticky.SideBCount = Math.Max(0, ensemble.SideB.Count); } + // Lock-time scale is a peak floor - live refresh may dip without erasing the theater. + sticky.PeakParticipants = Math.Max(sticky.PeakParticipants, liveN); return true; } @@ -477,11 +485,14 @@ namespace IdleSpectator; } LiveSceneStickyState sticky = scene.Sticky; + UpdateMopUp(sticky, Time.unscaledTime); + GetPresentationCounts(sticky, out int countA, out int countB); bool wipedOpposing = sticky.HasOpposingSides && sticky.TotalCount > 0 - && (sticky.SideACount <= 0 || sticky.SideBCount <= 0); + && (countA <= 0 || countB <= 0); + int liveN = Math.Max(0, countA) + Math.Max(0, countB); int scaleN = wipedOpposing - ? Math.Max(1, sticky.TotalCount) + ? Math.Max(1, liveN) : Math.Max(3, sticky.PeakParticipants); var ensemble = new LiveEnsemble { @@ -495,7 +506,7 @@ namespace IdleSpectator; Key = sticky.SideAKey, Display = sticky.SideADisplay, KingdomDisplay = sticky.SideAKingdom, - Count = sticky.SideACount, + Count = countA, Best = focus }, SideB = new EnsembleSide @@ -503,14 +514,224 @@ namespace IdleSpectator; Key = sticky.SideBKey, Display = sticky.SideBDisplay, KingdomDisplay = sticky.SideBKingdom, - Count = sticky.SideBCount, + Count = countB, Best = related }, - ParticipantCount = sticky.TotalCount + ParticipantCount = liveN }; return EventReason.Ensemble(ensemble); } + /// + /// Presentation counts for combat tips: mop-up forces a thin recovering camp to 0 + /// until it reaches . + /// + public static void GetPresentationCounts(LiveSceneStickyState sticky, out int countA, out int countB) + { + countA = 0; + countB = 0; + if (sticky == null) + { + return; + } + + countA = Math.Max(0, sticky.SideACount); + countB = Math.Max(0, sticky.SideBCount); + if (!sticky.MopUpActive) + { + return; + } + + if (countA > 0 && countA < WipeRecoverMin) + { + countA = 0; + } + + if (countB > 0 && countB < WipeRecoverMin) + { + countB = 0; + } + } + + private static void UpdateMopUp(LiveSceneStickyState sticky, float now) + { + if (sticky == null || !sticky.HasOpposingSides) + { + return; + } + + int a = Math.Max(0, sticky.SideACount); + int b = Math.Max(0, sticky.SideBCount); + if (a <= 0 || b <= 0) + { + if (!sticky.MopUpActive) + { + sticky.MopUpActive = true; + sticky.MopUpSince = now; + } + + return; + } + + if (!sticky.MopUpActive) + { + return; + } + + // Leave mop-up only when both camps are meaningfully back. + if (a >= WipeRecoverMin && b >= WipeRecoverMin) + { + sticky.MopUpActive = false; + sticky.MopUpSince = -999f; + } + } + + /// + /// True when two sticky scenes share the same unordered kingdom theater + /// (WarFront ↔ local Mass on Igguorn vs The Godo). + /// Matches asset ids, display names, and KingdomDisplay aliases. + /// + public static bool SharesKingdomTheater(InterestCandidate a, InterestCandidate b) + { + if (a?.Sticky == null || b?.Sticky == null) + { + return false; + } + + if (!TryKingdomAliasPair(a.Sticky, out string[] a1, out string[] a2) + || !TryKingdomAliasPair(b.Sticky, out string[] b1, out string[] b2)) + { + return false; + } + + return (AliasesOverlap(a1, b1) && AliasesOverlap(a2, b2)) + || (AliasesOverlap(a1, b2) && AliasesOverlap(a2, b1)); + } + + /// True when sticky kingdom pair matches a stored crisis theater. + public static bool SharesKingdomTheater(InterestCandidate c, string theaterA, string theaterB) + { + if (c?.Sticky == null + || string.IsNullOrEmpty(theaterA) + || string.IsNullOrEmpty(theaterB) + || !TryKingdomAliasPair(c.Sticky, out string[] a1, out string[] a2)) + { + return false; + } + + string[] tA = KingdomAliases(theaterA); + string[] tB = KingdomAliases(theaterB); + return (AliasesOverlap(a1, tA) && AliasesOverlap(a2, tB)) + || (AliasesOverlap(a1, tB) && AliasesOverlap(a2, tA)); + } + + public static bool TryKingdomPair(LiveSceneStickyState sticky, out string a, out string b) + { + a = ""; + b = ""; + if (!TryKingdomAliasPair(sticky, out string[] aAliases, out string[] bAliases)) + { + return false; + } + + a = aAliases.Length > 0 ? aAliases[0] : ""; + b = bAliases.Length > 0 ? bAliases[0] : ""; + return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) && !KingdomEq(a, b); + } + + private static bool TryKingdomAliasPair( + LiveSceneStickyState sticky, + out string[] sideA, + out string[] sideB) + { + sideA = null; + sideB = null; + if (sticky == null) + { + return false; + } + + if (sticky.Frame == EnsembleFrame.KingdomVsKingdom) + { + sideA = KingdomAliases(sticky.SideAKey, sticky.SideAKingdom, sticky.SideADisplay); + sideB = KingdomAliases(sticky.SideBKey, sticky.SideBKingdom, sticky.SideBDisplay); + } + else + { + sideA = KingdomAliases(sticky.SideAKingdom, sticky.SideAKey, sticky.SideADisplay); + sideB = KingdomAliases(sticky.SideBKingdom, sticky.SideBKey, sticky.SideBDisplay); + } + + return sideA.Length > 0 && sideB.Length > 0 && !AliasesOverlap(sideA, sideB); + } + + private static string[] KingdomAliases(params string[] raw) + { + var list = new System.Collections.Generic.List(6); + for (int i = 0; i < raw.Length; i++) + { + AddKingdomAlias(list, raw[i]); + if (!string.IsNullOrEmpty(raw[i])) + { + AddKingdomAlias(list, LiveEnsemble.KingdomDisplay(raw[i])); + } + } + + return list.ToArray(); + } + + private static void AddKingdomAlias(System.Collections.Generic.List list, string key) + { + string n = NormKingdom(key); + if (string.IsNullOrEmpty(n)) + { + return; + } + + for (int i = 0; i < list.Count; i++) + { + if (string.Equals(list[i], n, StringComparison.Ordinal)) + { + return; + } + } + + list.Add(n); + } + + private static bool AliasesOverlap(string[] a, string[] b) + { + if (a == null || b == null || a.Length == 0 || b.Length == 0) + { + return false; + } + + for (int i = 0; i < a.Length; i++) + { + for (int j = 0; j < b.Length; j++) + { + if (KingdomEq(a[i], b[j])) + { + return true; + } + } + } + + return false; + } + + private static string NormKingdom(string key) + { + return string.IsNullOrEmpty(key) ? "" : key.Trim().ToLowerInvariant(); + } + + private static bool KingdomEq(string a, string b) + { + return !string.IsNullOrEmpty(a) + && !string.IsNullOrEmpty(b) + && string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + private static void ApplyToEnsemble( LiveSceneStickyState sticky, LiveEnsemble ensemble, diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 5cf2eb8..97bc84a 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -53,6 +53,10 @@ internal static class HarnessScenarios case "combat_focus": case "combat_tip_align": return CombatFocusAlign(); + case "spectacle_mage_focus": + case "evil_mage_focus": + case "mage_focus": + return SpectacleMageFocus(); case "combat_stability_live": case "combat_live_proof": return CombatStabilityLive(); @@ -115,6 +119,12 @@ internal static class HarnessScenarios return StoryDecisionIntentLive(); case "story_aftermath_war": return StoryAftermathWar(); + case "story_crisis_war": + case "crisis_war": + return StoryCrisisWar(); + case "story_crisis_disaster": + case "crisis_disaster": + return StoryCrisisDisaster(); case "story_aftermath_partner_truth": case "aftermath_partner_truth": return StoryAftermathPartnerTruth(); @@ -1185,6 +1195,42 @@ internal static class HarnessScenarios }; } + /// + /// Idle evil mages must win ambient fill over civ chores (soak: mages never focused). + /// Also gates mage drop powers as camera Signal. + /// + private static List SpectacleMageFocus() + { + return new List + { + Step("smf0", "dismiss_windows"), + Step("smf1", "wait_world"), + Step("smf2", "set_setting", expect: "enabled", value: "true"), + Step("smf3", "fast_timing", value: "true"), + Step("smf4", "assert", expect: "library_ambient_policy_ok"), + // One civ chore unit + one mage - polluted leftover wars must not own the pick. + Step("smf5", "spawn", asset: "human", count: 1), + Step("smf6", "spawn", asset: "evil_mage", count: 1), + Step("smf7", "spectator", value: "off"), + Step("smf8", "spectator", value: "on"), + Step("smf9", "interest_end_session"), + Step("smf10", "interest_story_clear"), + Step("smf11", "interest_variety_clear"), + Step("smf12", "interest_expire_pending", value: ""), + Step("smf12b", "interest_expire_pending", value: "war"), + Step("smf12c", "interest_expire_pending", value: "combat"), + Step("smf13", "pick_unit", asset: "evil_mage"), + Step("smf14", "focus", asset: "evil_mage"), + Step("smf15", "interest_end_session"), + // Quiet map: ambient fill should land on the spectacle, not a random human. + Step("smf16", "director_run", wait: 2.0f), + Step("smf17", "assert", expect: "unit_asset", asset: "evil_mage"), + Step("smf18", "assert", expect: "has_focus", value: "true"), + Step("smf90", "fast_timing", value: "false"), + Step("smf99", "snapshot"), + }; + } + /// /// Combat tip subject must match focus; death handoff rewrites tip; cold combat clears fight tip. /// @@ -1217,15 +1263,36 @@ internal static class HarnessScenarios Step("cf21e", "assert", expect: "tip_same"), Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), Step("cf21w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"), - // Attack-target swap must keep locked pair partner (tip may escalate 1v2→Skirmish). + // Attack-target swap must keep locked pair partner and Duel tip order (A↔B hold). Step("cf21p", "remember_related"), + Step("cf21p2", "remember_tip"), Step("cf21q", "combat_swap_attack_target", asset: "wolf"), Step("cf21r", "wait", value: "0.35"), Step("cf21s", "director_run", wait: 0.6f), Step("cf21t", "combat_maintain_focus"), Step("cf21u", "combat_maintain_focus"), Step("cf21v", "assert", expect: "related_same"), - Step("cf21x", "assert", expect: "tip_matches_any", value: "Duel|Skirmish|Battle|Mass|fighting| vs "), + Step("cf21v2", "assert", expect: "tip_same"), + // Past duel MaxWatch while scrap still hot: must not max_cap into a new partner. + Step("cf21y", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"), + Step("cf21y2", "remember_tip"), + Step("cf21y3", "remember_related"), + Step("cf21z", "age_current", value: "30"), + Step("cf21z2", "director_run", wait: 0.55f), + Step("cf21z3", "combat_maintain_focus"), + Step("cf21z4", "assert", expect: "related_same"), + Step("cf21z5", "assert", expect: "tip_same"), + // First partner dies mid-scrap: lock stays; tip must not hop to a new Duel name. + Step("cf21aa", "remember_related"), + Step("cf21ab", "combat_kill_related"), + Step("cf21ae", "director_run", wait: 0.55f), + Step("cf21af", "combat_maintain_focus"), + Step("cf21ag", "assert", expect: "related_same"), + Step("cf21ah", "assert", expect: "tip_matches_any", value: "is fighting|Skirmish -|Battle -|Mass -"), + Step("cf21ai", "assert", expect: "tip_not_contains", value: "Duel"), + Step("cf21x", "assert", expect: "tip_matches_any", value: "is fighting|Skirmish|Battle|Mass|fighting| vs "), + // Re-seed a living wolf for death-handoff (original partner was killed above). + Step("cf21aj", "spawn", asset: "wolf", count: 1), Step("cf22", "interest_clear_follow", asset: "wolf"), Step("cf23", "combat_maintain_focus"), Step("cf24", "assert", expect: "has_focus", value: "true"), @@ -1258,13 +1325,14 @@ internal static class HarnessScenarios Step("cf47g", "combat_maintain_focus"), Step("cf47h", "assert", expect: "focus_same"), - // Spectacle theater lead: evil mage vs human mob holds the mage, not a random human. + // Spectacle theater lead: evil mage vs a huge civ mob holds the mage, not a king. Step("cf48", "interest_end_session"), Step("cf48b", "spawn", asset: "evil_mage", count: 1), Step("cf48c", "spawn", asset: "human", count: 1), Step("cf48d", "pick_unit", asset: "evil_mage"), Step("cf48e", "interest_combat_session", asset: "evil_mage", value: "human", expect: "cf_mage"), - Step("cf48f", "combat_ensemble_escalate", value: "8"), + // Soak shape: 1 spectacle vs a massed civ side (not a tiny 1v8 skirmish). + Step("cf48f", "combat_ensemble_escalate", value: "24"), Step("cf48g", "combat_wire_attack_sides", asset: "evil_mage", value: "human"), Step("cf48h", "wait", value: "0.35"), Step("cf48i", "combat_maintain_focus"), @@ -1283,6 +1351,7 @@ internal static class HarnessScenarios Step("cf48q", "assert", expect: "focus_same"), Step("cf48r", "assert", expect: "unit_asset", asset: "evil_mage"), Step("cf48s", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("cf48s2", "assert", expect: "tip_not_contains", value: "Duel"), // Natural Duel → multi: seed packs without tip rewrite; maintain must escalate. Step("cf50", "interest_end_session"), @@ -1302,11 +1371,34 @@ internal static class HarnessScenarios Step("cf59b", "assert", expect: "tip_not_contains", value: "Duel"), Step("cf59c", "assert", expect: "tip_matches_any", value: " vs "), - // Cold combat must not keep fight / ensemble tips. + // Natural cold: locked pair sleeps (Moving shape). Must go inactive / leave Duel + // without mark_combat_cold - ambient nearby scraps must not refresh hotness. + Step("cf29b", "combat_isolate_pair", value: "20"), Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"), + Step("cf30w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"), Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), + Step("cf31a", "assert", expect: "interest_session_active", value: "true"), + Step("cf31b", "interest_release_force"), + Step("cf31b2", "combat_isolate_pair", value: "20"), + Step("cf31c", "combat_disengage_pair"), + Step("cf31c2", "status_apply", value: "sleeping", label: "12"), + Step("cf31c3", "focus", asset: "wolf"), + Step("cf31c4", "status_apply", value: "sleeping", label: "12"), + Step("cf31c5", "combat_disengage_pair"), + Step("cf31c6", "focus", asset: "human"), + Step("cf31c7", "assert", expect: "focus_fighting", value: "false"), + Step("cf31c8", "assert", expect: "related_fighting", value: "false"), + // Re-stamp LastSeenAt cold after the last assert tick; age past MaxWatch → max_cap. + Step("cf31c9", "combat_disengage_pair"), + Step("cf31c10", "age_current", value: "70"), + Step("cf31h", "director_run", wait: 1.0f), + // Cold duel key must be gone. A later natural scrap may still say Duel - that is + // fine. Living partner: must never mint false "stands over the fallen". + Step("cf31i", "assert", expect: "interest_no_key", value: "cf_cold"), + Step("cf31k2", "assert", expect: "tip_not_contains", value: "stands over"), + // Force-cold tip scrub still required when a combat session remains. Step("cf32", "interest_release_force"), - Step("cf33", "interest_mark_inactive", value: "1"), + Step("cf33", "interest_mark_combat_cold"), Step("cf34", "combat_maintain_focus"), Step("cf35", "assert", expect: "tip_not_contains", value: " vs "), Step("cf35b", "assert", expect: "tip_not_contains", value: "Duel"), @@ -1495,8 +1587,23 @@ internal static class HarnessScenarios Step("ch34", "assert", expect: "tip_matches_any", value: "Duel -"), Step("ch35", "assert", expect: "tip_not_contains", value: "falls in love"), + // Leadership death (king_killed=88 < epic floor) must still cut mid-scrap + // (soak: king slaying dropped while parked on a Duel). + Step("ch35b", "interest_expire_pending", value: "ch_lover"), + Step("ch35c", "world_log_feed", asset: "king_killed"), + Step("ch35d", "assert", expect: "pending_contains", value: "king_killed"), + Step("ch35e", "director_run", wait: 1.2f), + Step("ch35f", "assert", expect: "tip_matches_any", value: "King killed|king_killed|King"), + Step("ch35g", "assert", expect: "tip_not_contains", value: "Duel -"), + // Extremely urgent (catalog epic-world EventStrength) may cut mid-fight. - Step("ch36", "interest_expire_pending", value: "ch_lover"), + Step("ch36", "interest_end_session"), + Step("ch36b", "interest_expire_pending", value: ""), + Step("ch36c", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack_u"), + Step("ch36d", "combat_isolate_pair", value: "20"), + Step("ch36e", "status_apply", asset: "human", value: "invincible"), + Step("ch36f", "status_apply", asset: "wolf", value: "invincible"), + Step("ch36g", "assert", expect: "tip_matches_any", value: "Duel -"), Step("ch37", "interest_inject", asset: "human", label: "UrgentCut", tier: "Epic", expect: "ch_urgent", value: "lead=event;evt=150;char=10;force=false;ttl=60"), Step("ch38", "assert", expect: "pending_contains", value: "ch_urgent"), @@ -1545,18 +1652,19 @@ internal static class HarnessScenarios Step("sa8", "pick_unit", asset: "human"), Step("sa9", "focus", asset: "human"), Step("sa10", "interest_end_session"), + Step("sa10b", "interest_story_clear"), + Step("sa10c", "interest_variety_clear"), + Step("sa10d", "interest_expire_pending", value: ""), Step("sa20", "interest_combat_session", asset: "human", value: "wolf", expect: "sa_pack"), Step("sa20b", "combat_isolate_pair", value: "20"), Step("sa21", "status_apply", asset: "human", value: "invincible"), - Step("sa22", "status_apply", asset: "wolf", value: "invincible"), Step("sa23", "assert", expect: "tip_matches_any", value: "Duel -"), Step("sa24", "interest_expire_pending", value: ""), + // Survivor aftermath requires a real fallen partner - kill the wolf first. + Step("sa24b", "combat_kill_related"), Step("sa25", "age_current", wait: 10f), Step("sa26", "interest_mark_combat_cold"), - // Cold climax tip while phase is Aftermath must not show a lying spine. - Step("sa26b", "assert", expect: "tip_matches_any", value: "Duel -"), - Step("sa26c", "assert", expect: "story_phase", value: "Aftermath"), - Step("sa26d", "assert", expect: "story_spine", value: "empty"), + // Inject runs on director tick after cold (not on mark alone). Step("sa27", "director_run", wait: 1.5f), Step("sa28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"), Step("sa29", "assert", expect: "tip_asset", value: "aftermath_"), @@ -1600,8 +1708,89 @@ internal static class HarnessScenarios } /// - /// Combat aftermath must not name a living lover/friend as the fallen - /// (soak: "stands over Clemond" after a duel vs someone else). + /// Large WarFront opens a crisis chapter; forced closer is epilogue_crisis. + /// + private static List StoryCrisisWar() + { + return new List + { + Step("scw0", "dismiss_windows"), + Step("scw1", "wait_world"), + Step("scw2", "set_setting", expect: "enabled", value: "true"), + Step("scw3", "fast_timing", value: "true"), + Step("scw4", "spawn", asset: "human", count: 2), + Step("scw5", "spectator", value: "off"), + Step("scw6", "spectator", value: "on"), + Step("scw7", "pick_unit", asset: "human"), + Step("scw8", "focus", asset: "human"), + Step("scw9", "interest_end_session"), + Step("scw10", "interest_story_clear"), + Step("scw20", "interest_war_session", asset: "human", expect: "scw_war"), + Step("scw21", "director_run", wait: 0.6f), + Step("scw22", "assert", expect: "tip_matches_any", value: "War -"), + // Small harness kingdoms are below live enter threshold - force chapter open. + Step("scw22b", "interest_crisis_begin"), + Step("scw23", "assert", expect: "crisis_active", value: "true"), + Step("scw24", "assert", expect: "crisis_kind", value: "War"), + Step("scw30", "interest_expire_pending", value: ""), + Step("scw31", "interest_mark_sticky_cold"), + Step("scw32", "interest_crisis_end"), + Step("scw33", "director_run", wait: 1.5f), + Step("scw34", "assert", expect: "tip_asset", value: "epilogue_crisis"), + Step("scw35", "assert", expect: "tip_matches_any", value: "surveys the aftermath of the crisis"), + Step("scw36", "assert", expect: "story_spine", value: "empty"), + Step("scw37", "assert", expect: "caption_not_contains", value: "Fighting ·"), + Step("scw90", "fast_timing", value: "false"), + Step("scw99", "snapshot"), + }; + } + + /// + /// EarthquakeActive disaster opens a crisis chapter; forced closer is epilogue_crisis. + /// Stacked storm tips during disaster cool must not open a second chapter. + /// + private static List StoryCrisisDisaster() + { + return new List + { + Step("scd0", "dismiss_windows"), + Step("scd1", "wait_world"), + Step("scd2", "set_setting", expect: "enabled", value: "true"), + Step("scd3", "fast_timing", value: "true"), + Step("scd4", "spawn", asset: "human", count: 2), + Step("scd5", "spectator", value: "off"), + Step("scd6", "spectator", value: "on"), + Step("scd7", "pick_unit", asset: "human"), + Step("scd8", "focus", asset: "human"), + Step("scd9", "interest_end_session"), + Step("scd10", "interest_story_clear"), + Step("scd20", "interest_earthquake_session", asset: "human", expect: "scd_quake"), + Step("scd21", "director_run", wait: 0.6f), + Step("scd22", "assert", expect: "tip_matches_any", value: "earthquake"), + Step("scd22b", "interest_crisis_begin"), + Step("scd23", "assert", expect: "crisis_active", value: "true"), + Step("scd24", "assert", expect: "crisis_kind", value: "Disaster"), + Step("scd30", "interest_expire_pending", value: ""), + Step("scd31", "interest_mark_sticky_cold"), + Step("scd32", "interest_crisis_end"), + Step("scd33", "director_run", wait: 1.5f), + Step("scd34", "assert", expect: "tip_asset", value: "epilogue_crisis"), + Step("scd35", "assert", expect: "tip_matches_any", value: "surveys the aftermath of the crisis"), + // End closer tip → Done + disaster cool. Fresh storm tip must not reopen. + Step("scd36", "interest_end_session"), + Step("scd37", "assert", expect: "crisis_active", value: "false"), + Step("scd40", "interest_expire_pending", value: ""), + Step("scd41", "interest_earthquake_session", asset: "human", expect: "scd_quake2"), + Step("scd42", "director_run", wait: 1.0f), + Step("scd43", "assert", expect: "crisis_active", value: "false"), + Step("scd90", "fast_timing", value: "false"), + Step("scd99", "snapshot"), + }; + } + + /// + /// Living theater partner must not mint survivor "stands over" prose + /// (soak: Norron vs Nerari attack gaps). /// private static List StoryAftermathPartnerTruth() { @@ -1634,10 +1823,9 @@ internal static class HarnessScenarios Step("saf25", "age_current", wait: 10f), Step("saf26", "interest_mark_combat_cold"), Step("saf27", "director_run", wait: 1.5f), - // Living theater partner → generic fallen prose; never the lover's name. - Step("saf28", "assert", expect: "tip_contains", value: "stands over the fallen"), - Step("saf29", "assert", expect: "tip_asset", value: "aftermath_"), - Step("saf30", "assert", expect: "tip_not_partner"), + // Living theater partner: never mint survivor "stands over the fallen". + Step("saf28", "assert", expect: "tip_not_contains", value: "stands over"), + Step("saf29", "assert", expect: "tip_not_partner"), Step("saf90", "fast_timing", value: "false"), Step("saf99", "snapshot"), }; @@ -1668,13 +1856,14 @@ internal static class HarnessScenarios Step("san20", "interest_combat_session", asset: "human", value: "wolf", expect: "san_pack"), Step("san20b", "combat_isolate_pair", value: "20"), Step("san21", "status_apply", asset: "human", value: "invincible"), - Step("san22", "status_apply", asset: "wolf", value: "invincible"), Step("san23", "assert", expect: "tip_matches_any", value: "Duel -"), // High-score cast-life distractor (expect id must NOT contain "hatch" or the // harness maps it onto just_got_out_of_egg and it can sticky-win the cold). Step("san24", "interest_inject", asset: "human", label: "{a} munches quietly nearby", tier: "Action", expect: "san_cast_noise", value: "lead=event;evt=88;char=10;force=false;ttl=60"), + // Real fallen partner required for survivor aftermath. + Step("san24b", "combat_kill_related"), Step("san25", "age_current", wait: 10f), Step("san26", "interest_mark_combat_cold"), Step("san27", "director_run", wait: 1.5f), @@ -2225,6 +2414,14 @@ internal static class HarnessScenarios Step("cws31", "assert", expect: "tip_not_contains", value: "(0)"), Step("cws32", "assert", expect: "tip_not_contains", value: " vs "), Step("cws33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + // Mop-up hysteresis: thin 1-foe count must stay pack-framed (no vs), not Mass↔Battle. + // Do not maintain here - live foes would re-enroll SideB outside mop-up policy. + Step("cws40", "combat_set_sticky_side_count", value: "b:1"), + Step("cws41", "assert", expect: "tip_not_contains", value: " vs "), + Step("cws42", "assert", expect: "tip_not_contains", value: "(0)"), + Step("cws43", "combat_set_sticky_side_count", value: "b:1"), + Step("cws44", "assert", expect: "tip_not_contains", value: " vs "), + Step("cws45", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), Step("cws90", "fast_timing", value: "false"), Step("cws99", "snapshot"), }; @@ -2562,6 +2759,7 @@ internal static class HarnessScenarios Step("wfs7", "pick_unit", asset: "human"), Step("wfs8", "focus", asset: "human"), Step("wfs9", "interest_end_session"), + Step("wfs10", "interest_story_clear"), Step("wfs20", "interest_war_session", asset: "human", expect: "wfs_war"), Step("wfs21", "assert", expect: "tip_matches_any", value: "War -"), @@ -2586,6 +2784,19 @@ internal static class HarnessScenarios Step("wfs52", "assert", expect: "tip_matches_any", value: "War -"), Step("wfs53", "assert", expect: "dossier_matches_focus"), + // Same-kingdom Mass peer must not steal the War tip (soak War↔Mass thrash). + // Headcounts may refresh; tip must stay War - … (not Mass/Battle). + Step("wfs60", "remember_tip"), + Step("wfs61", "war_inject_theater_mass", value: "8:6"), + Step("wfs62", "director_run", wait: 1.0f), + Step("wfs63", "war_maintain_focus"), + Step("wfs64", "assert", expect: "tip_matches_any", value: "War -"), + Step("wfs65", "assert", expect: "tip_not_contains", value: "Mass -"), + Step("wfs66", "assert", expect: "tip_not_contains", value: "Battle -"), + Step("wfs67", "director_run", wait: 0.8f), + Step("wfs68", "assert", expect: "tip_matches_any", value: "War -"), + Step("wfs69", "assert", expect: "tip_not_contains", value: "Mass -"), + Step("wfs90", "fast_timing", value: "false"), Step("wfs98", "screenshot", value: "hud-war-front-sticky.png"), Step("wfs99", "snapshot"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 54d5b33..ba10635 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -179,8 +179,9 @@ public sealed class InterestCandidate /// /// Lock durable 1v1 owner/partner ids (no-op when owner missing). - /// First living partner sticks across attack_target swaps; replace only when the - /// locked partner is dead/missing (or ). + /// First living partner sticks across attack_target swaps. Replace only when the + /// locked partner is confirmed dead and the owner is no longer fighting + /// (or ). Lookup misses never unlock the partner. /// public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false) { @@ -212,12 +213,52 @@ public sealed class InterestCandidate return; } - // Locked partner still alive: keep them. Dead/missing: adopt the new foe once. - Actor locked = LiveEnsemble.FindTrackedActor(PairPartnerId); - if (locked == null || !locked.isAlive()) + // Prefer FindUnitById: FindAliveById null means dead OR miss - misses must not thrash. + Actor locked = EventFeedUtil.FindUnitById(PairPartnerId); + bool lockedAlive = false; + bool lockedKnown = locked != null; + try { - PairPartnerId = pid; + lockedAlive = locked != null && locked.isAlive(); } + catch + { + // Destroyed Unity object - treat as miss (keep lock). + lockedKnown = false; + lockedAlive = false; + } + + if (lockedAlive) + { + return; + } + + if (!lockedKnown) + { + // Unknown / unloaded / destroyed - keep the first partner id. + return; + } + + // Confirmed dead. While the owner is still swinging (spectacle 1vN / scrap mop), + // do not chain-adopt the next attack_target into the durable lock. + Actor ownerActor = null; + try + { + ownerActor = owner != null && owner.isAlive() + ? owner + : EventFeedUtil.FindAliveById(PairOwnerId); + } + catch + { + ownerActor = EventFeedUtil.FindAliveById(PairOwnerId); + } + + if (ownerActor != null && LiveEnsemble.IsCombatParticipant(ownerActor)) + { + return; + } + + PairPartnerId = pid; } public bool HasCombatPairLock => PairOwnerId != 0; diff --git a/IdleSpectator/InterestCompletion.cs b/IdleSpectator/InterestCompletion.cs index 9d97f31..320ac1b 100644 --- a/IdleSpectator/InterestCompletion.cs +++ b/IdleSpectator/InterestCompletion.cs @@ -25,7 +25,12 @@ public static class InterestCompletion } float age = now - sessionStartedAt; - if (age >= candidate.MaxWatch) + // Combat / war caps are owned by InterestDirector (MaxWatchFor + CombatFightIsHot). + // An early MaxWatch gate here made live scraps look cold so max_cap re-picked + // a new Duel partner every cap (spectacle 1vN thrash). + if (age >= candidate.MaxWatch + && candidate.Completion != InterestCompletionKind.CombatActive + && candidate.Completion != InterestCompletionKind.WarFront) { return false; } @@ -94,52 +99,34 @@ public static class InterestCompletion return false; } + float now = Time.unscaledTime; Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { - return false; + // Spectacle / pair owner may still be swinging after a brief Follow gap. + unit = ResolveCombatHotAnchor(c); } - float now = Time.unscaledTime; - bool hot = false; - try + // Single participant predicate: sleeping/stunned/frozen are cold even with a stale + // attack_target pointer (combat_focus natural cold; Norron attack-gap truth). + bool hot = unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit); + if (!hot && unit != null && unit.isAlive() && RelatedStillFightingUs(c, unit)) { - if (unit.has_attack_target) - { - hot = true; - } - else if (unit.hasTask() && unit.ai?.task != null - && (unit.ai.task.in_combat || unit.ai.task.is_fireman)) - { - hot = true; - } - else if (RelatedStillFightingUs(c, unit)) - { - hot = true; - } - } - catch - { - // ignore + hot = true; } - if (!hot && c.AssetId == "live_battle") + if (!hot) { - // Chunk-local only - never CollectAllCombatFighters during sticky maintain. - Vector3 pos = c.Position; - try - { - if (c.FollowUnit != null && c.FollowUnit.isAlive()) - { - pos = c.FollowUnit.current_position; - } - } - catch - { - // keep Position - } + hot = PairPartnerStillFighting(c); + } - hot = WorldActivityScanner.HasLiveCombatNear(pos, 18f); + // Collective Mass/Battle: enrolled sticky fighters bridge attack_target gaps. + // Never use unscoped HasLiveCombatNear - ambient scraps within 18 tiles were + // pinning cold Duel pairs (Neen vs Yaaeore held by nearby Igguorn melees). + bool pairOrDuel = IsPairOrDuelCombat(c); + if (!hot && !pairOrDuel && c.HasStickyCombatSides) + { + hot = StickyRosterStillFighting(c); } if (hot) @@ -149,9 +136,129 @@ public static class InterestCompletion return true; } - // Hysteresis: WorldBox clears attack_target between swings. + // Pair/Duel: bridge swing gaps. Survivor aftermath requires a fallen partner, so a + // longer pair hold is safe - too short dropped mid-duel into false cold (Norron soak). + const float pairHysteresisSeconds = 5f; const float combatHysteresisSeconds = 8f; - return now - c.LastSeenAt < combatHysteresisSeconds; + float hold = pairOrDuel ? pairHysteresisSeconds : combatHysteresisSeconds; + return now - c.LastSeenAt < hold; + } + + /// + /// Pair-locked / Duel tips stay hot only while the owned cast fights - not nearby strangers. + /// + private static bool IsPairOrDuelCombat(InterestCandidate c) + { + if (c == null) + { + return false; + } + + if (c.HasCombatPairLock) + { + return true; + } + + string tip = c.Label ?? ""; + return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool PairPartnerStillFighting(InterestCandidate c) + { + if (c == null) + { + return false; + } + + Actor partner = c.RelatedUnit; + if ((partner == null || !partner.isAlive()) && c.PairPartnerId != 0) + { + partner = LiveEnsemble.FindTrackedActor(c.PairPartnerId); + } + + if (partner == null || !partner.isAlive()) + { + return false; + } + + try + { + return LiveEnsemble.IsCombatParticipant(partner); + } + catch + { + return false; + } + } + + private static bool StickyRosterStillFighting(InterestCandidate c) + { + LiveSceneStickyState sticky = c?.Sticky; + if (sticky == null) + { + return false; + } + + if (RosterIdsStillFighting(sticky.SideAIds) + || RosterIdsStillFighting(sticky.SideBIds)) + { + return true; + } + + return false; + } + + private static bool RosterIdsStillFighting(List ids) + { + if (ids == null || ids.Count == 0) + { + return false; + } + + for (int i = 0; i < ids.Count; i++) + { + Actor unit = LiveEnsemble.FindTrackedActor(ids[i]); + if (unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit)) + { + return true; + } + } + + return false; + } + + private static Actor ResolveCombatHotAnchor(InterestCandidate c) + { + if (c == null) + { + return null; + } + + if (c.TheaterLeadId != 0) + { + Actor lead = LiveEnsemble.FindTrackedActor(c.TheaterLeadId); + if (lead != null && lead.isAlive()) + { + return lead; + } + } + + if (c.PairOwnerId != 0) + { + Actor owner = LiveEnsemble.FindTrackedActor(c.PairOwnerId); + if (owner != null && owner.isAlive()) + { + return owner; + } + } + + if (c.RelatedUnit != null && c.RelatedUnit.isAlive()) + { + return c.RelatedUnit; + } + + return null; } private static bool WarFrontStillActive(InterestCandidate c) @@ -181,6 +288,13 @@ public static class InterestCompletion sidesLive = TryResolveWarStillActive(c.CorrelationKey); } + // Sticky war tips stay hot while opposing camps remain locked, even when kingdom + // asset lookup fails (harness synthetic keys / display-name aliases). + if (!sidesLive && c.HasStickyCombatSides && c.CombatPeakParticipants > 0) + { + sidesLive = true; + } + if (sidesLive) { c.LastSeenAt = now; @@ -568,6 +682,11 @@ public static class InterestCompletion return false; } + if (LiveEnsemble.IsCombatIncapacitated(foe) || LiveEnsemble.IsCombatIncapacitated(unit)) + { + return false; + } + try { if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive()) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 39d6a55..b186270 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -490,6 +490,9 @@ public static class InterestDirector _current = null; _interrupted = null; InterestRegistry.Clear(); + // Drop harness/live crisis overlays so a war_front_sticky leftover cannot + // fire epilogue_crisis into the next idle soak. + StoryPlanner.Clear(); } public static void PauseForBrowsing(string banner = "Paused (viewing history)") @@ -864,13 +867,14 @@ public static class InterestDirector _current.StampCombatPair(ownedFocus, ownedRelated); } - bool pairOwned = IsNamedPairCombatOwnership(_current) - || (_current.HasCombatPairLock - && ownedFocus != null - && ownedFocus.isAlive() - && ownedRelated != null - && ownedRelated.isAlive() - && !HasStickyCollectiveMulti(_current)); + // 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. @@ -897,13 +901,54 @@ public static class InterestDirector bool stickyPackEscalate = HasStickyCollectiveMulti(_current) && stickyCampN >= 3 && (focusFighting || relatedFighting); - // Both left the scrap (chores / idle): do not hold a Duel camera on them. - // Keep durable A vs B order while either still fights (no tip flip on attack gaps). + // 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) { - // Fall through to sticky rebuild / live fighter pick. + 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; } - else if (!stickyPackEscalate + + if (!stickyPackEscalate && !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) { string pairLabel = ""; @@ -1195,16 +1240,8 @@ public static class InterestDirector } else { - // Leaving NamedPair for a sided multi: drop durable pair lock so later maintains - // cannot ApplyNamedPairHold and collapse Mass/Battle back to Duel. - if (pairOwned - && fighters >= 3 - && (ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble) - || HasStickyCollectiveMulti(_current))) - { - _current.PairOwnerId = 0; - _current.PairPartnerId = 0; - } + // 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. @@ -1316,6 +1353,19 @@ public static class InterestDirector 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 @@ -1409,6 +1459,7 @@ public static class InterestDirector }; 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; @@ -1913,8 +1964,9 @@ public static class InterestDirector return false; } - return scene.CombatPeakParticipants >= 3 - || string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase); + // AssetId==live_battle alone is not enough - ambient probes set it without a real multi. + int enrolled = scene.CombatSideACount + scene.CombatSideBCount; + return scene.CombatPeakParticipants >= 3 || enrolled >= 3; } /// @@ -2010,13 +2062,24 @@ public static class InterestDirector return weight; } - // 1-vs-mob: prefer the thinner side's champion (evil mage vs humans). - if (ownSideCount > 0 - && foeSideCount > 0 - && ownSideCount < foeSideCount - && foeSideCount >= 3) + bool outnumbered = ownSideCount > 0 + && foeSideCount > 0 + && ownSideCount < foeSideCount + && foeSideCount >= 3; + if (!outnumbered) { - weight += CombatTheaterLeadOutnumberedBonus; + return weight; + } + + // 1-vs-mob: prefer the thinner side's champion (evil mage vs humans). + weight += CombatTheaterLeadOutnumberedBonus; + + // Spectacle on the thin side must beat crowned kings / renown stacks on the mob + // (soak: 1 evil mage vs 100+ elves still followed a random elf). + if (WorldActivityScanner.IsSpectaclePublic(actor)) + { + float ratio = foeSideCount / (float)Mathf.Max(1, ownSideCount); + weight += Mathf.Clamp(40f + ratio * 6f, 60f, 140f); } return weight; @@ -2166,6 +2229,18 @@ public static class InterestDirector 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; @@ -2462,6 +2537,124 @@ public static class InterestDirector 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, @@ -2476,8 +2669,8 @@ public static class InterestDirector return; } - // Drop ambient species/kingdom sticky that leaked in during cluster Refresh, - // but never wipe a real multi camp roster (3+ enrolled) while holding a Duel tip. + // 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 @@ -2563,23 +2756,16 @@ public static class InterestDirector return false; } - // Collective multi (including Duel mid-escalate after sticky Refresh) leaves the pair. - if (fighters >= 3 && HasStickyCollectiveMulti(scene)) + 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; } - if (HasStickyCollectiveMulti(scene)) - { - string tip = scene.Label ?? ""; - if (!tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - bool duelLabeled = !string.IsNullOrEmpty(scene.Label) - && scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); bool smallCluster = fighters <= 2; if (!duelLabeled && !smallCluster && !scene.ForceActive) { @@ -2730,14 +2916,44 @@ public static class InterestDirector } /// - /// If the active combat scene already covers this battle anchor, refresh it in place + /// If the active combat/war scene already covers this battle anchor, refresh it in place /// instead of registering a second live_battle candidate. /// public static bool TryAbsorbCombatBattle(InterestEvent battle) { - if (battle == null - || _current == null - || _current.Completion != InterestCompletionKind.CombatActive) + if (battle == null || _current == null) + { + return false; + } + + // War chapter owns the kingdom theater - local Mass scraps refresh follow in place. + if (_current.Completion == InterestCompletionKind.WarFront + && BattleSharesCurrentWarTheater(battle)) + { + if (battle.FollowUnit != null && battle.FollowUnit.isAlive()) + { + _current.FollowUnit = battle.FollowUnit; + _current.SubjectId = EventFeedUtil.SafeId(battle.FollowUnit); + try + { + _current.Position = battle.FollowUnit.current_position; + } + catch + { + // keep + } + } + + if (battle.ParticipantCount > _current.ParticipantCount) + { + _current.ParticipantCount = battle.ParticipantCount; + } + + ApplyWarFrontMaintain(forceWatch: true); + return true; + } + + if (_current.Completion != InterestCompletionKind.CombatActive) { return false; } @@ -2786,6 +3002,68 @@ public static class InterestDirector return true; } + /// + /// True when a scanner battle belongs to the current WarFront kingdom pair + /// (kingdom keys on the battle follow / related, or proximity inside war theater). + /// + private static bool BattleSharesCurrentWarTheater(InterestEvent battle) + { + if (battle == null + || _current == null + || _current.Completion != InterestCompletionKind.WarFront + || _current.Sticky == null + || !StickyScoreboard.TryKingdomPair(_current.Sticky, out string warA, out string warB)) + { + return false; + } + + string followK = LiveEnsemble.KingdomKeyOf(battle.FollowUnit); + string relatedK = LiveEnsemble.KingdomKeyOf(battle.RelatedUnit); + bool followMatch = KingdomOnWarPair(followK, warA, warB); + bool relatedMatch = KingdomOnWarPair(relatedK, warA, warB); + if (followMatch && (relatedMatch || string.IsNullOrEmpty(relatedK))) + { + return true; + } + + if (followMatch && relatedMatch) + { + return true; + } + + // Near the war camera anchor inside war theater radius. + Vector3 anchor = battle.Position; + Vector3 cur = _current.Position; + try + { + if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) + { + cur = _current.FollowUnit.current_position; + } + } + catch + { + // keep + } + + float dx = anchor.x - cur.x; + float dy = anchor.y - cur.y; + float r = StickyScoreboard.WarTheaterRadius; + return followMatch && dx * dx + dy * dy <= r * r; + } + + private static bool KingdomOnWarPair(string kingdom, string warA, string warB) + { + if (string.IsNullOrEmpty(kingdom)) + { + return false; + } + + string k = kingdom.Trim().ToLowerInvariant(); + return string.Equals(k, warA, StringComparison.OrdinalIgnoreCase) + || string.Equals(k, warB, StringComparison.OrdinalIgnoreCase); + } + /// Harness: apply a synthetic combat ensemble onto the current scene. public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble) { @@ -2828,13 +3106,8 @@ public static class InterestDirector _current.CombatPeakParticipants = authoredN; } - // Leaving NamedPair: clear durable pair lock so maintain may keep collective framing. - if (authoredN >= 3 || LiveEnsemble.HasOpposingCollectiveSides(ensemble)) - { - _current.PairOwnerId = 0; - _current.PairPartnerId = 0; - _current.ClearTheaterLead(); - } + // Keep PairOwner/Partner + theater lead across escalate. Clearing them forced + // Mass→Duel collapse onto whichever attack_target the lead had that tick. Actor focus = ensemble.Focus; Actor related = ensemble.Related; @@ -2912,6 +3185,70 @@ public static class InterestDirector 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, @@ -2922,7 +3259,7 @@ public static class InterestDirector ParticipantCount = scene.CombatSideACount + scene.CombatSideBCount }; - var sideA = new EnsembleSide + var sideAFallback = new EnsembleSide { Key = scene.CombatSideAKey, Display = scene.CombatSideADisplay, @@ -2930,7 +3267,7 @@ public static class InterestDirector Count = scene.CombatSideACount, Best = focus }; - var sideB = new EnsembleSide + var sideBFallback = new EnsembleSide { Key = scene.CombatSideBKey, Display = scene.CombatSideBDisplay, @@ -2939,32 +3276,8 @@ public static class InterestDirector Best = related }; - bool focusOnB = false; - if (focus != null && scene.Sticky != null) - { - long id = EventFeedUtil.SafeId(focus); - for (int i = 0; i < scene.Sticky.SideBIds.Count; i++) - { - if (scene.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; - } + stickyEns.SideA = sideAFallback; + stickyEns.SideB = sideBFallback; } /// Harness: force one combat focus maintenance pass. @@ -3890,6 +4203,8 @@ public static class InterestDirector 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)) { @@ -3899,14 +4214,41 @@ public static class InterestDirector return false; } - float now = Time.unscaledTime; + // 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. + // 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( @@ -4032,8 +4374,10 @@ public static class InterestDirector return true; } - // StoryPlanner hard-arc hold during aftermath/epilogue against unrelated peers. - float storyHold = StoryPlanner.ArcHoldMargin(_current, candidate); + // 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); @@ -4130,6 +4474,13 @@ public static class InterestDirector 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; @@ -4140,6 +4491,37 @@ public static class InterestDirector && 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. @@ -4344,6 +4726,21 @@ public static class InterestDirector 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 @@ -4428,8 +4825,24 @@ public static class InterestDirector 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; } @@ -4438,28 +4851,56 @@ public static class InterestDirector return false; } - CollectCombatActorIds(current, out long curA, out long curB); - CollectCombatActorIds(next, out long nextA, out long nextB); - if (curA == 0 && curB == 0) + // 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) { - return false; - } + 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; + } - // Exact same unordered pair is same scene (registry key should match) - not noise. - if (curA != 0 - && curB != 0 - && nextA != 0 - && nextB != 0 - && ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA))) - { return false; } // Shared fighter + different partner = attack_target thrash. - return SharesCombatFighterId(current, nextA) + 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) @@ -4715,6 +5156,12 @@ public static class InterestDirector float now = Time.unscaledTime; _lastAmbientAt = now; + // Crisis chapters own the camera - no ambient channel-surfing mid-war/disaster. + if (StoryPlanner.SuppressAmbientFill) + { + return; + } + if (!force && _current != null && SessionProtected(now, now - _currentStartedAt) && InterestScoring.IsHotScore(_current.TotalScore)) { diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index e6ceae0..9fd3e80 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -42,6 +42,34 @@ public class StoryWeights public float ledgerBleedVillage = 0.82f; /// Extra FixedDwell beat cool for family chapters (seconds, stacks with base). public float familyLifeBeatCooldownSeconds = 90f; + + // --- Crisis chapters (Phase 4) --- + /// WarFront needs at least this many participants to open a crisis chapter. + public int crisisWarParticipantMin = 8; + /// Disaster / outbreak EventStrength floor to open a crisis chapter. + public float crisisDisasterStrengthMin = 95f; + /// Score boost for tips that belong to the live crisis. + public float crisisOwnershipBoost = 22f; + /// Extra cut-in margin while watching a crisis tip against unrelated peers. + public float crisisHoldMargin = 40f; + /// Keep the chapter after the last crisis signal for this many seconds. + public float crisisLingerSeconds = 10f; + /// + /// Disaster / outbreak linger after the last WorldLog pulse (tornado tips are short FixedDwell). + /// + public float crisisDisasterLingerSeconds = 28f; + /// Hard cap on crisis Active phase length (seconds). + public float crisisMaxSeconds = 180f; + /// Do not reopen a crisis for this many seconds after Done. + public float crisisCooldownSeconds = 45f; + /// + /// Longer cool after disaster/outbreak closers so stacked storm tips cannot double-close. + /// + public float crisisDisasterCooldownSeconds = 120f; + /// Forced closer strength when a crisis chapter ends. + public float crisisEpilogueStrength = 52f; + /// Forced closer max watch (seconds). + public float crisisEpilogueMaxWatch = 16f; } /// Serializable weight block — field names must match scoring-model.json "weights". @@ -152,6 +180,15 @@ public class ScoringWeights public float scannerHotBonus = 18f; public float scannerWarmBonus = 8f; public float scannerSpectacleBonus = 12f; + /// + /// Idle spectacles (evil mage pacing, dragon roosting) keep at least this action score + /// so warm civic chores cannot own ambient forever. + /// + public float scannerSpectacleIdleFloor = 48f; + /// + /// Prefer a live spectacle over a non-spectacle ambient pick when within this score gap. + /// + public float scannerSpectaclePreferMargin = 28f; public float scannerActionKillsFactor = 0.2f; public float scannerActionKillsCap = 10f; public float scannerWarriorBonus = 6f; diff --git a/IdleSpectator/Story/CrisisChapter.cs b/IdleSpectator/Story/CrisisChapter.cs new file mode 100644 index 0000000..0541551 --- /dev/null +++ b/IdleSpectator/Story/CrisisChapter.cs @@ -0,0 +1,50 @@ +namespace IdleSpectator; + +/// Parallel multi-minute chapter over war / disaster / outbreak (not a short StoryArc). +public enum CrisisKind +{ + None = 0, + War = 1, + Disaster = 2, + Outbreak = 3 +} + +public enum CrisisPhase +{ + Active = 0, + Epilogue = 1, + Done = 2 +} + +/// +/// Long bias overlay: ownership boost, ambient suppress, forced closer. +/// Lives beside short so unrelated tips do not thrash chapter state. +/// +public sealed class CrisisChapter +{ + public CrisisKind Kind = CrisisKind.None; + public CrisisPhase Phase = CrisisPhase.Done; + public string AnchorKey = ""; + public string Id = ""; + public float StartedAt; + public float LastSignalAt; + public float PhaseStartedAt; + public long FollowId; + public bool EpilogueInjected; + /// Unordered kingdom theater pair for war chapters (matches local Mass scraps). + public string TheaterKeyA = ""; + public string TheaterKeyB = ""; + + public bool IsLive => Phase == CrisisPhase.Active || Phase == CrisisPhase.Epilogue; + + public void Advance(CrisisPhase next, float now) + { + if (Phase == next) + { + return; + } + + Phase = next; + PhaseStartedAt = now; + } +} diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs index c7c8124..5564142 100644 --- a/IdleSpectator/Story/StoryPlanner.cs +++ b/IdleSpectator/Story/StoryPlanner.cs @@ -11,6 +11,8 @@ namespace IdleSpectator; public static class StoryPlanner { private static StoryArc _active; + private static CrisisChapter _crisis; + private static float _crisisCooldownUntil; private static bool _coldHookFired; private static string _coldHookClimaxKey = ""; private static string _coldHookAnchor = ""; @@ -18,6 +20,14 @@ public static class StoryPlanner public static StoryArc Active => _active != null && _active.IsActive ? _active : null; + /// Live crisis chapter overlay (war / disaster / outbreak), or null. + public static CrisisChapter Crisis => _crisis != null && _crisis.IsLive ? _crisis : null; + + public static bool CrisisActive => Crisis != null; + + /// Character-fill ambient is suppressed while a crisis chapter is live. + public static bool SuppressAmbientFill => CrisisActive; + /// /// Quiet dossier spine: "Duel · Aftermath". /// Empty when no live arc, or when the current watch tip is not that story beat. @@ -25,6 +35,12 @@ public static class StoryPlanner public static string FormatSpineLabel(InterestCandidate watch = null, StoryArc arc = null) { watch = watch ?? InterestDirector.CurrentCandidate; + // Crisis closer is not a short-arc spine beat. + if (IsCrisisEpilogueCandidate(watch)) + { + return ""; + } + // Sticky combat can reframe Battle↔Duel without SwitchTo - keep Kind honest first. SyncActiveClimax(watch); arc = arc ?? Active; @@ -263,6 +279,8 @@ public static class StoryPlanner public static void Clear() { _active = null; + _crisis = null; + _crisisCooldownUntil = 0f; _coldHookFired = false; _coldHookClimaxKey = ""; _coldHookAnchor = ""; @@ -272,6 +290,8 @@ public static class StoryPlanner public static void Tick(float now) { + TickCrisis(now); + if (_active == null || !_active.IsActive) { return; @@ -306,6 +326,19 @@ public static class StoryPlanner if (IsStoryAssetCandidate(next)) { + if (IsCrisisEpilogueCandidate(next) && _crisis != null && _crisis.IsLive) + { + _crisis.Advance(CrisisPhase.Epilogue, now); + // Crisis closer is not a short-arc beat - end any live combat arc so + // dossier spine / cast state cannot linger under the closer. + if (_active != null && _active.IsActive) + { + _active.Advance(StoryPhase.Done, now); + } + + return; + } + if (_active != null && _active.IsActive) { if (string.Equals(next.AssetId, StoryReason.EpilogueRelated, StringComparison.OrdinalIgnoreCase) @@ -324,6 +357,8 @@ public static class StoryPlanner return; } + NoteCrisisFromCandidate(next, now); + if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold)) { BeginOrRefreshArc(next, kind, hardHold, now); @@ -339,7 +374,18 @@ public static class StoryPlanner public static void OnEndCurrent(InterestCandidate ended, float now) { - if (ended == null || _active == null || !_active.IsActive) + if (ended == null) + { + return; + } + + // Crisis chapters often have no soft StoryArc - still close when the closer tip ends. + if (IsCrisisEpilogueCandidate(ended) && _crisis != null && _crisis.IsLive) + { + CloseCrisis(now); + } + + if (_active == null || !_active.IsActive) { return; } @@ -542,32 +588,111 @@ public static class StoryPlanner public static float OwnershipBoost(InterestCandidate c) { + float crisisBoost = CrisisOwnershipBoost(c); if (!OwnsCandidate(c) || _active == null || !_active.HardHold) { // Aftermath/epilogue always get a modest boost so they can win quiet grace. if (IsStoryAssetCandidate(c)) { - return InterestScoringConfig.Story.arcOwnershipBoost; + return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost); } - return 0f; + return crisisBoost; } if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue) { - return InterestScoringConfig.Story.arcOwnershipBoost; + return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost); } - return 0f; + return crisisBoost; + } + + /// Score boost for tips that belong to the live crisis chapter. + public static float CrisisOwnershipBoost(InterestCandidate c) + { + if (!MatchesCrisis(c)) + { + return 0f; + } + + StoryWeights s = InterestScoringConfig.Story; + return s.crisisOwnershipBoost > 0f ? s.crisisOwnershipBoost : 22f; } /// - /// Raised cut-in margin while a hard arc holds Aftermath/Epilogue against unrelated peers. - /// Returns 0 when the planner does not impose an extra hold. + /// True when is part of the live crisis (signal tip or crisis closer). + /// + public static bool MatchesCrisis(InterestCandidate c) + { + if (c == null || _crisis == null || !_crisis.IsLive) + { + return false; + } + + if (IsCrisisEpilogueCandidate(c)) + { + return true; + } + + if (_crisis.Phase == CrisisPhase.Epilogue) + { + return false; + } + + // Live WarFront tip always belongs to an open war crisis (even below enter threshold). + if (_crisis.Kind == CrisisKind.War + && c.Completion == InterestCompletionKind.WarFront) + { + return true; + } + + // Local Mass/Battle on the war's kingdom pair keeps the war chapter warm / owned + // so WarFront ↔ Mass thrash cannot starve LastSignalAt. + if (_crisis.Kind == CrisisKind.War + && c.Completion == InterestCompletionKind.CombatActive + && StickyScoreboard.SharesKingdomTheater(c, _crisis.TheaterKeyA, _crisis.TheaterKeyB)) + { + return true; + } + + if (!TryClassifyCrisis(c, out CrisisKind kind, out string anchor, out _)) + { + return false; + } + + if (kind != _crisis.Kind) + { + return false; + } + + if (!string.IsNullOrEmpty(_crisis.AnchorKey) + && !string.IsNullOrEmpty(anchor) + && !string.Equals(_crisis.AnchorKey, anchor, StringComparison.Ordinal)) + { + // Same kind of crisis may refresh under a new theater key (war sides). + // Still match by kind while Active so count refreshes keep ownership. + return kind == CrisisKind.War || kind == CrisisKind.Disaster || kind == CrisisKind.Outbreak; + } + + return true; + } + + /// + /// True when the orange reason is a StoryPlanner FixedDwell beat (aftermath / epilogue). + /// Used to suppress stale combat task detail under story closers. + /// + public static bool IsStoryOwnedTip(InterestCandidate c) => + IsStoryAssetCandidate(c) || IsCrisisEpilogueCandidate(c); + + /// + /// Raised cut-in margin while Aftermath/Epilogue holds against unrelated peers. + /// Soft combat arcs also block same-cast life noise until the closer tip is watched + /// (soak/harness: eat inject must not starve "stands over" after a fallen duel). /// public static float ArcHoldMargin(InterestCandidate current, InterestCandidate next) { - if (_active == null || !_active.HardHold || !_active.IsActive) + if (_active == null || !_active.IsActive) { return 0f; } @@ -582,32 +707,120 @@ public static class StoryPlanner return 0f; } - // Hold only while the camera is still on the aftermath/epilogue beat. - // Cast life tips (parenthood, sleep) must not keep the raised wall or the village - // ping-pongs under a fake sticky margin. - if (!BelongsToActiveStoryBeat(current, _active)) - { - return 0f; - } - if (OwnsCandidate(next) || IsContinuationOf(current, next)) { return 0f; } - // Same cast life beats may still cut without the raised margin. - if (_active.ContainsCast(next.SubjectId)) + // Natural grief/love peers may still claim the closer. + if (IsNaturalAftermathPeer(next, current)) { return 0f; } StoryWeights s = InterestScoringConfig.Story; - return s.arcHoldMargin > 0f ? s.arcHoldMargin : 50f; + float margin = s.arcHoldMargin > 0f ? s.arcHoldMargin : 50f; + + // Soft duel: pending aftermath must not lose to same-cast eat/hatch noise. + if (!_active.HardHold) + { + if (OwnsCandidate(current) || BelongsToClimaxTheater(current, _active)) + { + return margin; + } + + return 0f; + } + + // Hard arcs: hold while the camera is still on the aftermath/epilogue beat. + // Unrelated cast life must not keep the wall after the camera already left the closer. + if (!BelongsToActiveStoryBeat(current, _active)) + { + return 0f; + } + + return margin; + } + + /// + /// Highest pending EventStrength for same-subject non-story life tips (cast noise ceiling). + /// + private static float PendingCastLifeCeiling(long subjectId) + { + if (subjectId == 0) + { + return 0f; + } + + float best = 0f; + PendingScratch.Clear(); + InterestRegistry.CopyPending(PendingScratch); + for (int i = 0; i < PendingScratch.Count; i++) + { + InterestCandidate p = PendingScratch[i]; + if (p == null || p.SubjectId != subjectId || IsStoryAssetCandidate(p)) + { + continue; + } + + if (IsNaturalAftermathPeer(p, null)) + { + continue; + } + + if (p.EventStrength > best) + { + best = p.EventStrength; + } + } + + return best; + } + + /// + /// Raised cut-in margin while watching a crisis tip against unrelated peers. + /// Epic peers at/above crisisDisasterStrengthMin still cut freely. + /// + public static float CrisisHoldMargin(InterestCandidate current, InterestCandidate next) + { + if (_crisis == null || !_crisis.IsLive || current == null || next == null) + { + return 0f; + } + + if (!MatchesCrisis(current) || MatchesCrisis(next)) + { + return 0f; + } + + StoryWeights s = InterestScoringConfig.Story; + float epicFloor = s.crisisDisasterStrengthMin > 0f ? s.crisisDisasterStrengthMin : 95f; + if (next.EventStrength >= epicFloor) + { + return 0f; + } + + return s.crisisHoldMargin > 0f ? s.crisisHoldMargin : 40f; } public static bool PreferOver(InterestCandidate a, InterestCandidate b) { - if (a == null || b == null || _active == null || !_active.IsActive) + if (a == null || b == null) + { + return false; + } + + if (CrisisActive) + { + bool aCrisis = MatchesCrisis(a); + bool bCrisis = MatchesCrisis(b); + if (aCrisis && !bCrisis) + { + return true; + } + } + + if (_active == null || !_active.IsActive) { return false; } @@ -1104,15 +1317,38 @@ public static class StoryPlanner } Actor related = ResolveAftermathRelated(climax, follow, arc.Kind); + // Combat survivor aftermath requires a real fallen theater partner. + // Attack-target gaps / sleep with both alive must not mint "stands over the fallen" + // (soak: Norron vs Nerari thrash). Dead partners often clear RelatedUnit after kill - + // still honor PairPartnerId / RelatedId when the unit is gone or confirmed dead. + if (IsCombatKind(arc.Kind) && !CombatTheaterPartnerIsFallen(climax, related)) + { + return false; + } + string assetId = PickAftermathAsset(arc.Kind, related); StoryWeights s = InterestScoringConfig.Story; DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId); float strength = catalog != null && !catalog.IsFallback ? catalog.EventStrength : s.aftermathStrength; - string label = StoryReason.AftermathLabel(assetId, follow, related); long followId = EventFeedUtil.SafeId(follow); long relatedId = EventFeedUtil.SafeId(related); + if (relatedId == 0) + { + relatedId = climax.PairPartnerId != 0 ? climax.PairPartnerId : climax.RelatedId; + } + + // Name fallen partners even when RelatedUnit was cleared after die(). + Actor relatedForLabel = related; + if (relatedForLabel == null && relatedId != 0) + { + relatedForLabel = EventFeedUtil.FindUnitById(relatedId); + } + + string label = StoryReason.AftermathLabel(assetId, follow, relatedForLabel); + // Beat pending same-cast life noise (eat/hatch injects) so soft-duel aftermath still lands. + strength = Mathf.Max(strength, PendingCastLifeCeiling(followId) + 24f); // Camera RelatedUnit must be living; dead theater partners stay as RelatedId for cast/naming. Actor relatedLive = null; if (related != null) @@ -1158,7 +1394,8 @@ public static class StoryPlanner MinWatch = s.aftermathMinWatch > 0f ? s.aftermathMinWatch : 12f, MaxWatch = s.aftermathMaxWatch > 0f ? s.aftermathMaxWatch : 20f, Completion = InterestCompletionKind.FixedDwell, - Resumable = true, + // Never park/resume - soak bounced "stands over the fallen" across life tips. + Resumable = false, CorrelationKey = "story:" + arc.AnchorKey, CreatedAt = now, LastSeenAt = now, @@ -1201,6 +1438,21 @@ public static class StoryPlanner return; } + // Crisis chapter owns the closer - do not also fire a related soft epilogue. + if (_crisis != null && _crisis.IsLive) + { + if (!_crisis.EpilogueInjected) + { + BeginCrisisEpilogue(now); + } + + if (_crisis.EpilogueInjected) + { + arc.Advance(StoryPhase.Done, now); + return; + } + } + Actor follow = null; Actor related = null; for (int i = 0; i < arc.CastIds.Count; i++) @@ -1272,7 +1524,7 @@ public static class StoryPlanner MinWatch = 8f, MaxWatch = s.epilogueMaxWatch > 0f ? s.epilogueMaxWatch : 14f, Completion = InterestCompletionKind.FixedDwell, - Resumable = true, + Resumable = false, CorrelationKey = "story:" + arc.AnchorKey, CreatedAt = now, LastSeenAt = now, @@ -1536,7 +1788,645 @@ public static class StoryPlanner } } + /// True when the theater partner exists and is confirmed dead. + private static bool TheaterPartnerIsFallen(Actor related) + { + if (related == null) + { + return false; + } + + try + { + return !related.isAlive(); + } + catch + { + // Unreachable / disposed actors count as fallen for naming. + return true; + } + } + + /// + /// Combat fallen check: live RelatedUnit, else durable pair / related ids after death clears. + /// Null living partner with no durable id is not fallen (attack-gap false aftermath). + /// + private static bool CombatTheaterPartnerIsFallen(InterestCandidate climax, Actor related) + { + if (TheaterPartnerIsFallen(related)) + { + return true; + } + + if (climax == null) + { + return false; + } + + long id = climax.PairPartnerId != 0 ? climax.PairPartnerId : climax.RelatedId; + if (id == 0) + { + return false; + } + + Actor byId = EventFeedUtil.FindUnitById(id); + if (byId == null) + { + // Removed from the world map after die() - treat as fallen. + return true; + } + + return TheaterPartnerIsFallen(byId); + } + private static bool IsStoryAssetCandidate(InterestCandidate c) => c != null && (StoryReason.IsStoryAsset(c.AssetId) || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)); + + private static bool IsCrisisEpilogueCandidate(InterestCandidate c) => + c != null + && (StoryReason.IsCrisisEpilogue(c.AssetId) + || (!string.IsNullOrEmpty(c.Key) + && c.Key.StartsWith("epilogue:crisis:", StringComparison.OrdinalIgnoreCase))); + + /// + /// Harness: open a crisis chapter from the current tip even when live counts are below + /// the product enter threshold (small harness kingdoms). + /// + public static bool HarnessBeginCrisisFromCurrent() + { + // Earthquake / epic WorldLog can open the chapter naturally before this step. + if (CrisisActive) + { + return true; + } + + InterestCandidate cur = InterestDirector.CurrentCandidate; + if (cur == null) + { + return false; + } + + float now = Time.unscaledTime; + CrisisKind kind = CrisisKind.None; + string anchor = cur.Key ?? "harness"; + if (cur.Completion == InterestCompletionKind.WarFront) + { + kind = CrisisKind.War; + } + else if (cur.Completion == InterestCompletionKind.EarthquakeActive + || string.Equals(cur.Category, "Disaster", StringComparison.OrdinalIgnoreCase) + || string.Equals(cur.AssetId, "earthquake", StringComparison.OrdinalIgnoreCase)) + { + kind = CrisisKind.Disaster; + anchor = "disaster"; + } + else if (cur.Completion == InterestCompletionKind.StatusOutbreak) + { + kind = CrisisKind.Outbreak; + anchor = "outbreak"; + } + else if (TryClassifyCrisis(cur, out CrisisKind classified, out string classifiedAnchor, out _)) + { + kind = classified; + anchor = classifiedAnchor; + } + + if (kind == CrisisKind.None) + { + return false; + } + + Actor follow = cur.FollowUnit; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.FindAliveById(cur.SubjectId); + } + + BeginCrisis(kind, anchor, follow, now); + StampCrisisTheater(_crisis, cur); + return CrisisActive; + } + + /// + /// Harness: open crisis from the current tip / live signal if needed, then force the closer. + /// + public static bool HarnessForceCrisisEpilogue() + { + float now = Time.unscaledTime; + if (_crisis == null || !_crisis.IsLive) + { + if (!HarnessBeginCrisisFromCurrent()) + { + if (!TryFindCrisisSignal(out CrisisKind kind, out string anchor, out Actor follow, out _)) + { + return false; + } + + BeginCrisis(kind, anchor, follow, now); + } + } + + BeginCrisisEpilogue(now); + return _crisis != null && _crisis.EpilogueInjected; + } + + /// Open or refresh crisis from a switched tip (before maintain can dip counts). + private static void NoteCrisisFromCandidate(InterestCandidate c, float now) + { + if (c == null) + { + return; + } + + if (TryClassifyCrisis(c, out CrisisKind kind, out string anchor, out _)) + { + if (_crisis != null && _crisis.IsLive && _crisis.Kind == kind) + { + if (_crisis.Phase == CrisisPhase.Epilogue) + { + return; + } + + _crisis.LastSignalAt = now; + Actor follow = c.FollowUnit; + if (follow != null && follow.isAlive()) + { + long id = EventFeedUtil.SafeId(follow); + if (id != 0) + { + _crisis.FollowId = id; + } + } + + if (!string.IsNullOrEmpty(anchor) + && !string.Equals(_crisis.AnchorKey, anchor, StringComparison.Ordinal)) + { + _crisis.AnchorKey = anchor; + _crisis.Id = "crisis:" + kind + ":" + anchor; + } + + return; + } + + if (_crisis != null && _crisis.IsLive && _crisis.Phase == CrisisPhase.Epilogue) + { + return; + } + + if (now < _crisisCooldownUntil && (_crisis == null || !_crisis.IsLive)) + { + return; + } + + Actor openFollow = c.FollowUnit; + if (openFollow == null || !openFollow.isAlive()) + { + openFollow = EventFeedUtil.FindAliveById(c.SubjectId); + } + + BeginCrisis(kind, anchor, openFollow, now); + return; + } + + // Still on a war front after counts dipped - keep the chapter warm. + if (_crisis != null + && _crisis.IsLive + && _crisis.Phase == CrisisPhase.Active + && _crisis.Kind == CrisisKind.War + && c.Completion == InterestCompletionKind.WarFront) + { + _crisis.LastSignalAt = now; + } + } + + private static void TickCrisis(float now) + { + StoryWeights s = InterestScoringConfig.Story; + if (TryFindCrisisSignal(out CrisisKind kind, out string anchor, out Actor follow, out _)) + { + if (_crisis != null && _crisis.IsLive && _crisis.Kind == kind) + { + // Closer already owns the exit - still time out even if WorldLog keeps pulsing. + if (_crisis.Phase == CrisisPhase.Epilogue) + { + float epiCap = s.crisisEpilogueMaxWatch > 0f ? s.crisisEpilogueMaxWatch : 16f; + if (now - _crisis.PhaseStartedAt > epiCap + 8f) + { + CloseCrisis(now); + } + + return; + } + + _crisis.LastSignalAt = now; + if (!string.IsNullOrEmpty(anchor) + && !string.Equals(_crisis.AnchorKey, anchor, StringComparison.Ordinal)) + { + _crisis.AnchorKey = anchor; + _crisis.Id = "crisis:" + kind + ":" + anchor; + } + + if (follow != null) + { + long id = EventFeedUtil.SafeId(follow); + if (id != 0) + { + _crisis.FollowId = id; + } + } + + return; + } + + if (_crisis != null && _crisis.IsLive && _crisis.Kind != kind) + { + // Do not replace a live closer with a peer storm tip. + if (_crisis.Phase == CrisisPhase.Epilogue) + { + return; + } + + // Stronger / different crisis class replaces the live chapter. + BeginCrisis(kind, anchor, follow, now); + return; + } + + if (now < _crisisCooldownUntil) + { + return; + } + + BeginCrisis(kind, anchor, follow, now); + return; + } + + // War theater still current (WarFront or same-kingdom Mass) - keep chapter warm. + InterestCandidate cur = InterestDirector.CurrentCandidate; + if (_crisis != null + && _crisis.IsLive + && _crisis.Phase == CrisisPhase.Active + && _crisis.Kind == CrisisKind.War + && cur != null + && MatchesCrisis(cur)) + { + _crisis.LastSignalAt = now; + return; + } + + if (_crisis == null || !_crisis.IsLive) + { + return; + } + + if (_crisis.Phase == CrisisPhase.Epilogue) + { + float epiCap = s.crisisEpilogueMaxWatch > 0f ? s.crisisEpilogueMaxWatch : 16f; + if (now - _crisis.PhaseStartedAt > epiCap + 8f) + { + CloseCrisis(now); + } + + return; + } + + float linger = CrisisLingerSeconds(_crisis.Kind, s); + float maxDur = s.crisisMaxSeconds > 0f ? s.crisisMaxSeconds : 180f; + bool stale = now - _crisis.LastSignalAt > linger; + bool timedOut = now - _crisis.StartedAt > maxDur; + if (stale || timedOut) + { + BeginCrisisEpilogue(now); + } + } + + private static float CrisisLingerSeconds(CrisisKind kind, StoryWeights s) + { + float linger = s.crisisLingerSeconds > 0f ? s.crisisLingerSeconds : 10f; + if (kind == CrisisKind.Disaster || kind == CrisisKind.Outbreak) + { + float disasterLinger = s.crisisDisasterLingerSeconds > 0f + ? s.crisisDisasterLingerSeconds + : 28f; + linger = Mathf.Max(linger, disasterLinger); + } + + return linger; + } + + private static void BeginCrisis(CrisisKind kind, string anchor, Actor follow, float now) + { + if (kind == CrisisKind.None) + { + return; + } + + string key = string.IsNullOrEmpty(anchor) ? kind.ToString().ToLowerInvariant() : anchor; + long followId = EventFeedUtil.SafeId(follow); + _crisis = new CrisisChapter + { + Kind = kind, + Phase = CrisisPhase.Active, + AnchorKey = key, + Id = "crisis:" + kind + ":" + key, + StartedAt = now, + LastSignalAt = now, + PhaseStartedAt = now, + FollowId = followId, + EpilogueInjected = false + }; + StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate); + if (followId != 0) + { + CausalHeat.NoteFeatured(followId, 1.1f); + CharacterLedger.Touch(followId, 1.4f); + } + } + + private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip) + { + if (crisis == null) + { + return; + } + + if (tip?.Sticky != null + && StickyScoreboard.TryKingdomPair(tip.Sticky, out string a, out string b)) + { + crisis.TheaterKeyA = a; + crisis.TheaterKeyB = b; + return; + } + + // Fall back to follow / related kingdoms when sticky keys are not locked yet. + string fa = LiveEnsemble.KingdomKeyOf(tip?.FollowUnit); + string fb = LiveEnsemble.KingdomKeyOf(tip?.RelatedUnit); + if (!string.IsNullOrEmpty(fa) && !string.IsNullOrEmpty(fb) + && !string.Equals(fa, fb, StringComparison.OrdinalIgnoreCase)) + { + crisis.TheaterKeyA = fa.Trim().ToLowerInvariant(); + crisis.TheaterKeyB = fb.Trim().ToLowerInvariant(); + } + } + + private static void BeginCrisisEpilogue(float now) + { + if (_crisis == null || !_crisis.IsLive) + { + return; + } + + if (_crisis.EpilogueInjected) + { + _crisis.Advance(CrisisPhase.Epilogue, now); + return; + } + + if (!TryInjectCrisisEpilogue(_crisis, now)) + { + CloseCrisis(now); + return; + } + + _crisis.EpilogueInjected = true; + _crisis.Advance(CrisisPhase.Epilogue, now); + _crisis.LastSignalAt = now; + // Arm cool at closer inject so stacked disaster WorldLog tips cannot open a second + // chapter the moment CloseCrisis runs while the storm is still logging. + ArmCrisisCooldown(now, _crisis.Kind); + } + + private static void CloseCrisis(float now) + { + if (_crisis == null) + { + return; + } + + CrisisKind kind = _crisis.Kind; + _crisis.Advance(CrisisPhase.Done, now); + ArmCrisisCooldown(now, kind); + } + + private static void ArmCrisisCooldown(float now, CrisisKind kind) + { + StoryWeights s = InterestScoringConfig.Story; + float cool = s.crisisCooldownSeconds > 0f ? s.crisisCooldownSeconds : 45f; + if (kind == CrisisKind.Disaster || kind == CrisisKind.Outbreak) + { + float disasterCool = s.crisisDisasterCooldownSeconds > 0f + ? s.crisisDisasterCooldownSeconds + : 120f; + cool = Mathf.Max(cool, disasterCool); + } + + _crisisCooldownUntil = Mathf.Max(_crisisCooldownUntil, now + cool); + } + + private static bool TryFindCrisisSignal( + out CrisisKind kind, + out string anchor, + out Actor follow, + out float intensity) + { + kind = CrisisKind.None; + anchor = ""; + follow = null; + intensity = 0f; + + InterestCandidate best = null; + CrisisKind bestKind = CrisisKind.None; + string bestAnchor = ""; + float bestIntensity = 0f; + + InterestCandidate cur = InterestDirector.CurrentCandidate; + if (TryClassifyCrisis(cur, out CrisisKind curKind, out string curAnchor, out float curIntensity) + && curIntensity >= bestIntensity) + { + best = cur; + bestKind = curKind; + bestAnchor = curAnchor; + bestIntensity = curIntensity; + } + + PendingScratch.Clear(); + InterestRegistry.CopyPending(PendingScratch); + for (int i = 0; i < PendingScratch.Count; i++) + { + InterestCandidate p = PendingScratch[i]; + if (!TryClassifyCrisis(p, out CrisisKind pKind, out string pAnchor, out float pIntensity)) + { + continue; + } + + if (pIntensity > bestIntensity) + { + best = p; + bestKind = pKind; + bestAnchor = pAnchor; + bestIntensity = pIntensity; + } + } + + if (best == null || bestKind == CrisisKind.None) + { + return false; + } + + kind = bestKind; + anchor = bestAnchor; + intensity = bestIntensity; + follow = best.FollowUnit; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.FindAliveById(best.SubjectId); + } + + return true; + } + + private static bool TryClassifyCrisis( + InterestCandidate c, + out CrisisKind kind, + out string anchor, + out float intensity) + { + kind = CrisisKind.None; + anchor = ""; + intensity = 0f; + if (c == null || IsCrisisEpilogueCandidate(c)) + { + return false; + } + + StoryWeights s = InterestScoringConfig.Story; + int warMin = s.crisisWarParticipantMin > 0 ? s.crisisWarParticipantMin : 8; + float disasterMin = s.crisisDisasterStrengthMin > 0f ? s.crisisDisasterStrengthMin : 95f; + + if (c.Completion == InterestCompletionKind.WarFront) + { + // Enter on live theater scale only. Sticky PeakParticipants is a hold floor for + // scale labels - using it here let harness 12:8 locks open crisis on 2v1 worlds, + // then epilogue_crisis leaked into the live soak after war_front_sticky. + int sideA = Mathf.Max(0, c.CombatSideACount); + int sideB = Mathf.Max(0, c.CombatSideBCount); + int sides = sideA + sideB; + int n = Mathf.Max(c.ParticipantCount, sides); + bool contested = sideA >= 1 && sideB >= 1; + if (n >= warMin && contested) + { + kind = CrisisKind.War; + intensity = n + c.EventStrength * 0.01f; + anchor = !string.IsNullOrEmpty(c.CorrelationKey) + ? c.CorrelationKey + : (!string.IsNullOrEmpty(c.Key) ? c.Key : "war"); + return true; + } + } + + if (c.Completion == InterestCompletionKind.EarthquakeActive) + { + kind = CrisisKind.Disaster; + intensity = Mathf.Max(c.EventStrength, disasterMin); + // Family anchor: tornado / quake / meteor pulses share one storm chapter. + anchor = "disaster"; + return true; + } + + if (c.Completion == InterestCompletionKind.StatusOutbreak + && c.EventStrength >= disasterMin) + { + kind = CrisisKind.Outbreak; + intensity = c.EventStrength; + anchor = "outbreak"; + return true; + } + + if (c.EventStrength >= disasterMin + && (string.Equals(c.Category, "Disaster", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.AssetId, "earthquake", StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(c.Key) + && c.Key.IndexOf("earthquake", StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrEmpty(c.AssetId) + && c.AssetId.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0))) + { + kind = CrisisKind.Disaster; + intensity = c.EventStrength; + anchor = "disaster"; + return true; + } + + return false; + } + + private static bool TryInjectCrisisEpilogue(CrisisChapter crisis, float now) + { + if (crisis == null) + { + return false; + } + + Actor follow = EventFeedUtil.FindAliveById(crisis.FollowId); + if (follow == null || !follow.isAlive()) + { + follow = InterestDirector.CurrentCandidate?.FollowUnit; + if (follow == null || !follow.isAlive()) + { + follow = EventFeedUtil.AnyAliveUnit(); + } + } + + if (follow == null || !follow.isAlive()) + { + return false; + } + + StoryWeights s = InterestScoringConfig.Story; + string assetId = StoryReason.EpilogueCrisis; + DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId); + float strength = catalog != null && !catalog.IsFallback + ? catalog.EventStrength + : (s.crisisEpilogueStrength > 0f ? s.crisisEpilogueStrength : 52f); + string label = StoryReason.AftermathLabel(assetId, follow, null); + long followId = EventFeedUtil.SafeId(follow); + string key = "epilogue:crisis:" + crisis.Kind + ":" + crisis.AnchorKey + ":" + + followId + ":" + Mathf.RoundToInt(now * 1000f); + float maxWatch = s.crisisEpilogueMaxWatch > 0f ? s.crisisEpilogueMaxWatch : 16f; + + var candidate = new InterestCandidate + { + Key = key, + LeadKind = InterestLeadKind.EventLed, + Category = "Story", + Source = "story_planner", + AssetId = assetId, + Label = label, + FollowUnit = follow, + SubjectId = followId, + Position = follow.current_position, + EventStrength = strength, + VisualConfidence = 0.8f, + Novelty = 1f, + MinWatch = 8f, + MaxWatch = maxWatch, + Completion = InterestCompletionKind.FixedDwell, + Resumable = false, + CorrelationKey = crisis.Id, + CreatedAt = now, + LastSeenAt = now, + ExpiresAt = now + 45f + }; + + InterestCandidate registered = EventFeedUtil.RegisterCandidate(candidate); + if (registered == null) + { + return false; + } + + crisis.FollowId = followId; + CausalHeat.NoteFeatured(followId, 1.2f); + CharacterLedger.Touch(followId, 1.5f); + return true; + } } + diff --git a/IdleSpectator/Story/StoryReason.cs b/IdleSpectator/Story/StoryReason.cs index 054e0a4..1a593c4 100644 --- a/IdleSpectator/Story/StoryReason.cs +++ b/IdleSpectator/Story/StoryReason.cs @@ -9,6 +9,7 @@ public static class StoryReason public const string AftermathPlotFallout = "aftermath_plot_fallout"; public const string AftermathLoveLinger = "aftermath_love_linger"; public const string EpilogueRelated = "epilogue_related"; + public const string EpilogueCrisis = "epilogue_crisis"; public static bool IsStoryAsset(string assetId) { @@ -21,6 +22,11 @@ public static class StoryReason || assetId.StartsWith("epilogue_", System.StringComparison.OrdinalIgnoreCase); } + public static bool IsCrisisEpilogue(string assetId) + { + return string.Equals(assetId, EpilogueCrisis, System.StringComparison.OrdinalIgnoreCase); + } + public static string AftermathLabel(string assetId, Actor follow, Actor related) { string name = EventFeedUtil.SafeName(follow); @@ -49,10 +55,12 @@ public static class StoryReason return string.IsNullOrEmpty(other) ? name + " carries on after the story" : name + " seeks " + other + " after the story"; + case EpilogueCrisis: + return name + " surveys the aftermath of the crisis"; case AftermathSurvivor: default: - // Only name a dead theater partner. Living lover/friend fallbacks must never - // print as the fallen (soak: "stands over Clemond" after a duel vs someone else). + // Only name a dead theater partner. Never claim "the fallen" while the partner lives + // (soak: Norron vs Nerari attack gaps minted false survivor tips). if (related != null) { bool dead = false; @@ -69,6 +77,11 @@ public static class StoryReason { return name + " stands over " + other; } + + if (!dead) + { + return name + " catches their breath"; + } } return name + " stands over the fallen"; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index aa739f3..d204e40 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -1290,8 +1290,18 @@ public sealed class UnitDossier private static string BuildDetailLine(UnitDossier d) { // Compact text fallback for logs/harness: task + optional kingdom. + // Story/crisis FixedDwell reasons own the orange line - do not append combat + // task crumbs that read like a stale "Fighting · Kingdom" spine. StringBuilder sb = new StringBuilder(); - if (!string.IsNullOrEmpty(d.TaskText)) + InterestCandidate watch = InterestDirector.CurrentCandidate; + bool suppressCombatTask = StoryPlanner.IsStoryOwnedTip(watch) + || (!string.IsNullOrEmpty(d.ReasonLine) + && (d.ReasonLine.IndexOf("surveys the aftermath", + System.StringComparison.OrdinalIgnoreCase) >= 0 + || d.ReasonLine.IndexOf("stands over", + System.StringComparison.OrdinalIgnoreCase) >= 0)); + if (!string.IsNullOrEmpty(d.TaskText) + && !(suppressCombatTask && IsCombatishTask(d.TaskText))) { sb.Append(d.TaskText); } @@ -1322,6 +1332,21 @@ public sealed class UnitDossier return sb.ToString(); } + private static bool IsCombatishTask(string task) + { + if (string.IsNullOrEmpty(task)) + { + return false; + } + + string t = task.Trim(); + return t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf("Attack", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf("Battle", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf("Hunt", System.StringComparison.OrdinalIgnoreCase) >= 0; + } + private static void FillSex(UnitDossier d, Actor actor) { try diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index 24bb3e0..bdfd71d 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -11,13 +11,17 @@ namespace IdleSpectator; /// public static class WorldActivityScanner { - private static readonly HashSet SpectacleAssets = new HashSet + /// Seed ids - live actor_library discovery extends this each refresh. + private static readonly string[] SpectacleAssetSeed = { "dragon", "greg", "demon", "evil_mage", + "white_mage", "necromancer", + "plague_doctor", + "druid", "ufo", "cold_one", "walker", @@ -31,9 +35,31 @@ public static class WorldActivityScanner "skeleton", "zombie", "ghost", - "mush" + "mush", + "crabzilla" }; + private static readonly string[] SpectacleIdTokens = + { + "mage", + "necromancer", + "druid", + "plague_doctor", + "dragon", + "demon", + "cold_one", + "tumor", + "bioblob", + "assimilator", + "crabzilla", + "fire_elemental", + "god_finger" + }; + + private static HashSet _spectacleAssetIds; + private static float _spectacleIdsCachedAt = -999f; + private const float SpectacleIdsCacheSeconds = 30f; + private static readonly System.Random Rng = new System.Random(); private static float _lastScanAt = -999f; private static InterestEvent _cachedBest; @@ -105,11 +131,8 @@ public static class WorldActivityScanner continue; } - if (battle.getDeathsTotal() > 0) - { - return true; - } - + // Deaths alone are not live combat - finished battle tiles near a cold unit + // used to pin CombatStillActive forever. if (LiveEnsemble.TryBuildCombat(bpos, 14f, out LiveEnsemble near) && near != null && near.ParticipantCount >= 1) @@ -312,10 +335,21 @@ public static class WorldActivityScanner private static void ScanNow() { _cachedBattle = BuildHottestBattle(); - // Busy war maps: skip O(units) character scoring when a real fight already won. + // Busy war maps: skip full O(units) scoring, but still consider live spectacles + // (evil mage / dragon) so mass civ scraps cannot erase them forever. if (_cachedBattle != null && _cachedBattle.ParticipantCount >= 3) { - _cachedBest = _cachedBattle; + InterestEvent spectacle = BuildBestSpectacleUnit(); + if (spectacle != null + && (_cachedBattle.FollowUnit == null || !IsSpectacle(_cachedBattle.FollowUnit)) + && _cachedBattle.NotableParticipantCount <= 0) + { + // Anonymous scrap vs a live spectacle - ambient follows the spectacle. + _cachedBest = spectacle; + return; + } + + _cachedBest = PreferRicherAction(_cachedBattle, spectacle) ?? _cachedBattle; return; } @@ -877,6 +911,43 @@ public static class WorldActivityScanner return null; } + // Prefer spectacles over civ ambient. Idle/warm chores never beat a live mage/dragon; + // Hot combatants still need the prefer margin (spectacles should not erase every scrap). + if (!IsSpectacle(best)) + { + Actor spectacle = null; + float spectacleScore = float.MinValue; + for (int i = 0; i < alive.Count; i++) + { + Actor actor = alive[i]; + if (!IsSpectacle(actor)) + { + continue; + } + + float unitScore = ScoreActor(actor, speciesCounts); + if (unitScore > spectacleScore) + { + spectacleScore = unitScore; + spectacle = actor; + } + } + + if (spectacle != null) + { + bool bestHotFight = best.has_attack_target + && ActivityInterestTable.Classify(best) == ActivityBand.Hot; + float preferMargin = InterestScoringConfig.W.scannerSpectaclePreferMargin > 0f + ? InterestScoringConfig.W.scannerSpectaclePreferMargin + : 28f; + if (!bestHotFight || spectacleScore + preferMargin >= bestScore) + { + best = spectacle; + bestScore = spectacleScore; + } + } + } + ActivityBand band = ActivityInterestTable.Classify(best); SplitActorScore(best, speciesCounts, out float actionScore, out float charScore); int fighters = 0; @@ -1073,7 +1144,8 @@ public static class WorldActivityScanner actionScore += w.scannerWarmBonus; } - if (IsSpectacle(actor)) + bool spectacle = IsSpectacle(actor); + if (spectacle) { actionScore += w.scannerSpectacleBonus; } @@ -1111,17 +1183,32 @@ public static class WorldActivityScanner characterScore += w.scannerSingletonBonus; } + // Policy overlay (evil_mage / dragon / …) - same source as InterestScoring meta. + float speciesBonus = EventCatalogConfig.SpeciesBonus(speciesId); + if (speciesBonus > 0f) + { + characterScore += speciesBonus; + } + float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f; characterScore += Mathf.Min(w.scannerCharRenownCap, actor.renown / renownDiv); characterScore += Mathf.Min(w.scannerCharKillsCap, kills * w.scannerCharKillsFactor); // Cold kings barely score - do not let identity monopolize the camera. - if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor)) + // Spectacles keep full action (idle floor applied below). + if (band == ActivityBand.Cold && !actor.has_attack_target && !spectacle) { actionScore *= w.scannerColdActionMul; characterScore *= w.scannerColdCharMul; } + // Idle evil mages / dragons still beat warm chores for ambient fill. + if (spectacle && band == ActivityBand.Cold && !actor.has_attack_target) + { + float floor = w.scannerSpectacleIdleFloor > 0f ? w.scannerSpectacleIdleFloor : 42f; + actionScore = Mathf.Max(actionScore, floor); + } + actionScore += (actor.getID() % 7) * 0.01f; } @@ -1163,16 +1250,88 @@ public static class WorldActivityScanner return false; } - string id = actor.asset.id; - if (SpectacleAssets.Contains(id)) + return IsSpectacleAssetId(actor.asset.id); + } + + /// + /// Live spectacle actor / power id class (mages, dragons, demons, …). + /// Seed + token heuristics + live actor_library discovery. + /// + public static bool IsSpectacleAssetId(string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + return false; + } + + string id = assetId.Trim().ToLowerInvariant(); + if (id.StartsWith("boat_", StringComparison.Ordinal)) + { + return false; + } + + HashSet live = EnsureSpectacleAssetIds(); + if (live.Contains(id)) { return true; } - // Loose match for variant ids. - foreach (string key in SpectacleAssets) + return MatchesSpectacleToken(id); + } + + private static HashSet EnsureSpectacleAssetIds() + { + float now = Time.unscaledTime; + if (_spectacleAssetIds != null && now - _spectacleIdsCachedAt < SpectacleIdsCacheSeconds) { - if (id.Contains(key)) + return _spectacleAssetIds; + } + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < SpectacleAssetSeed.Length; i++) + { + set.Add(SpectacleAssetSeed[i]); + } + + List liveActors = ActivityAssetCatalog.EnumerateLiveActorAssetIds(); + for (int i = 0; i < liveActors.Count; i++) + { + string id = liveActors[i]; + if (string.IsNullOrEmpty(id) || id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (MatchesSpectacleToken(id) || set.Contains(id)) + { + set.Add(id); + } + } + + _spectacleAssetIds = set; + _spectacleIdsCachedAt = now; + return _spectacleAssetIds; + } + + private static bool MatchesSpectacleToken(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + string s = id.ToLowerInvariant(); + for (int i = 0; i < SpectacleIdTokens.Length; i++) + { + if (s.IndexOf(SpectacleIdTokens[i], StringComparison.Ordinal) >= 0) + { + return true; + } + } + + for (int i = 0; i < SpectacleAssetSeed.Length; i++) + { + if (s.IndexOf(SpectacleAssetSeed[i], StringComparison.OrdinalIgnoreCase) >= 0) { return true; } @@ -1181,6 +1340,53 @@ public static class WorldActivityScanner return false; } + /// Best living spectacle unit for ambient ranking (mages, dragons, …). + private static InterestEvent BuildBestSpectacleUnit() + { + Actor best = null; + float bestScore = float.MinValue; + Dictionary counts = CountSpeciesPopulations(); + foreach (Actor actor in EnumerateAliveUnits()) + { + if (actor == null || !actor.isAlive() || !IsSpectacle(actor)) + { + continue; + } + + if (actor.asset != null && actor.asset.is_boat) + { + continue; + } + + float score = ScoreActor(actor, counts); + if (score > bestScore) + { + bestScore = score; + best = actor; + } + } + + if (best == null) + { + return null; + } + + SplitActorScore(best, counts, out float actionScore, out float charScore); + float rankChar = InterestScoringConfig.W.scannerRankCharWeight; + return new InterestEvent + { + Score = actionScore + charScore * rankChar, + Position = best.current_position, + FollowUnit = best, + Label = "", + CreatedAt = Time.unscaledTime, + AssetId = best.asset != null ? best.asset.id : "spectacle", + ParticipantCount = 0, + NotableParticipantCount = 0, + CharacterSignificance = charScore + }; + } + private static bool IsSingletonSpecies(Actor actor, Dictionary speciesCounts) { if (actor?.asset == null) diff --git a/IdleSpectator/WorldLogEventHarness.cs b/IdleSpectator/WorldLogEventHarness.cs index e13a112..22bd73a 100644 --- a/IdleSpectator/WorldLogEventHarness.cs +++ b/IdleSpectator/WorldLogEventHarness.cs @@ -75,7 +75,11 @@ public static class WorldLogEventHarness } string labelSample = entry.MakeLabel("Alpha", "Beta"); - if (string.IsNullOrEmpty(labelSample)) + string emptySlotSample = entry.MakeLabel("", ""); + bool hanging = WorldLogEventEntry.IsHangingLabel(labelSample) + || WorldLogEventEntry.IsHangingLabel(emptySlotSample) + || string.IsNullOrWhiteSpace(emptySlotSample); + if (hanging) { emptyLabel++; } @@ -85,7 +89,7 @@ public static class WorldLogEventHarness { notes = "missing_authored"; } - else if (string.IsNullOrEmpty(labelSample)) + else if (hanging) { notes = "empty_label"; } diff --git a/IdleSpectator/event-catalog.json b/IdleSpectator/event-catalog.json index 0aa9265..86424a6 100644 --- a/IdleSpectator/event-catalog.json +++ b/IdleSpectator/event-catalog.json @@ -209,6 +209,26 @@ "id": "demon", "bonus": 12 }, + { + "id": "evil_mage", + "bonus": 14 + }, + { + "id": "necromancer", + "bonus": 14 + }, + { + "id": "white_mage", + "bonus": 12 + }, + { + "id": "plague_doctor", + "bonus": 12 + }, + { + "id": "druid", + "bonus": 10 + }, { "id": "angle", "bonus": 12 @@ -2630,6 +2650,13 @@ "category": "Story", "camera": true, "label": "{a} carries on after the story" + }, + { + "id": "epilogue_crisis", + "strength": 52, + "category": "Story", + "camera": true, + "label": "{a} surveys the aftermath of the crisis" } ], "traits": [ diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 48ed5ad..bdebc17 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.54", + "version": "0.29.0", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index 567d808..6c2a554 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -83,6 +83,8 @@ "scannerHotBonus": 18, "scannerWarmBonus": 8, "scannerSpectacleBonus": 12, + "scannerSpectacleIdleFloor": 48, + "scannerSpectaclePreferMargin": 28, "scannerActionKillsFactor": 0.2, "scannerActionKillsCap": 10, "scannerWarriorBonus": 6, @@ -129,7 +131,18 @@ "historyDensityWindowSeconds": 600, "ledgerBleedLifeChapter": 0.4, "ledgerBleedVillage": 0.82, - "familyLifeBeatCooldownSeconds": 90 + "familyLifeBeatCooldownSeconds": 90, + "crisisWarParticipantMin": 8, + "crisisDisasterStrengthMin": 95, + "crisisOwnershipBoost": 22, + "crisisHoldMargin": 40, + "crisisLingerSeconds": 10, + "crisisDisasterLingerSeconds": 28, + "crisisMaxSeconds": 180, + "crisisCooldownSeconds": 45, + "crisisDisasterCooldownSeconds": 120, + "crisisEpilogueStrength": 52, + "crisisEpilogueMaxWatch": 16 }, "sources": [ diff --git a/docs/event-catalog-discovery.md b/docs/event-catalog-discovery.md index 9d5f71a..428587f 100644 --- a/docs/event-catalog-discovery.md +++ b/docs/event-catalog-discovery.md @@ -93,6 +93,9 @@ Also stay B: diet/sleep/random/check ticks, `warrior_train_with_dummy`, boat tra Spell Signal: spectacle summons/curses/fire/teleport/meteor; FX silence/shield/cure/grass/vegetation/skeleton stay B. +Power Signal: disasters plus spectacle creature drops (`evil_mage`, `white_mage`, `necromancer`, +`druid`, `plague_doctor`, dragons/demons, …) via live spectacle asset discovery - not B ambient. + ## Patch-only / no catalog rows | Surface | Live / notes | Wire status | Priority | diff --git a/docs/story-planner.md b/docs/story-planner.md index 71bdc10..caf1dc4 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -106,6 +106,36 @@ Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_i / `combat_wiped_side` / `reason_life_principal` (`story_arc_combat`, `story_spine_scoped`, `story_aftermath_combat`, `story_family_variety`). +## Crisis chapters (Phase 4) + +Parallel overlay beside short `StoryArc` - war / disaster / outbreak thresholds open a +multi-minute chapter without thrashing when unrelated tips cut in. + +| Signal | Enter when | +|--------|------------| +| WarFront | **live** participants ≥ `crisisWarParticipantMin` (default 8), both sides present (peak lock alone does not enter) | +| EarthquakeActive / Disaster category | live or EventStrength ≥ `crisisDisasterStrengthMin` (95) | +| StatusOutbreak | EventStrength ≥ disaster floor | + +While live: + +- `crisisOwnershipBoost` + `crisisHoldMargin` on matching tips +- Character-fill ambient is suppressed (`SuppressAmbientFill`) +- Epic peers (≥ disaster floor) may still cut + +On exit (signal linger / max duration / harness `interest_crisis_end`): inject +`epilogue_crisis` (`{a} surveys the aftermath of the crisis`), then cooldown. + +Disaster / outbreak chapters use a shared family anchor (`disaster` / `outbreak`), a longer +linger after the last WorldLog pulse, and `crisisDisasterCooldownSeconds` (default 120) +armed when the closer injects so stacked tornado tips cannot double-close. + +War chapters stamp a kingdom theater pair. Local Mass/Battle on that pair keeps the chapter +warm and cannot steal the War tip (`war_theater_hold`). Crisis closers clear short-arc spine +and suppress combat task detail crumbs under the orange reason. + +Short-arc related epilogue is skipped when a crisis closer owns the exit. + ## Selection discipline - `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8). @@ -117,12 +147,14 @@ Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_i `scoring-model.json` → `story` block (loaded by `InterestScoringConfig.Story`). Catalog policy ids live under `event-catalog.json` → `story` -(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, …). +(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, `epilogue_crisis`, …). ## Harness | Scenario | Proves | |----------|--------| +| `story_crisis_war` | WarFront → crisis active → `epilogue_crisis` | +| `story_crisis_disaster` | EarthquakeActive → crisis → `epilogue_crisis`; cool blocks reopen | | `story_aftermath_combat` | duel cold → aftermath tip | | `story_aftermath_partner_truth` | aftermath never names living lover as fallen | | `story_aftermath_cast_noise` | hatch noise pending still gets aftermath | @@ -147,6 +179,10 @@ Catalog policy ids live under `event-catalog.json` → `story` | `story_ledger_revisit` | featured unit wins near-tie | ```bash +./scripts/harness-run.sh --repeat 3 story_crisis_war +./scripts/harness-run.sh --repeat 3 story_crisis_disaster +./scripts/harness-run.sh --repeat 3 story_aftermath_war +./scripts/harness-run.sh --repeat 3 war_front_sticky ./scripts/harness-run.sh --repeat 3 story_aftermath_combat ./scripts/harness-run.sh --repeat 3 story_aftermath_partner_truth ./scripts/harness-run.sh --repeat 3 story_aftermath_cast_noise @@ -168,7 +204,8 @@ Catalog policy ids live under `event-catalog.json` → `story` ## Files -- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels +- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels, crisis overlay +- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state - `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat - `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16) - `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels