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