feat(spectator): add crisis chapters and harden combat theater truth

- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers
- Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins
- Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight
- Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
This commit is contained in:
DazedAnon 2026-07-21 22:05:38 -05:00
parent 9a059c17dd
commit c23b2b9c1e
24 changed files with 3643 additions and 265 deletions

File diff suppressed because it is too large Load diff

View file

@ -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()

View file

@ -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);
}
/// <summary>
/// Drop empty-slot leftovers ("Tornado: ", "Event: ") so tips never show a bare colon.
/// </summary>
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;
}
/// <summary>True when a rendered tip would look empty or end with a hanging colon.</summary>
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);
}
}

View file

@ -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);

View file

@ -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))
{

View file

@ -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
}
}
/// <summary>
/// True when game state says the actor cannot meaningfully fight right now.
/// Uses Actor APIs + live sleeping status - not an authored id deny list.
/// </summary>
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;
}
/// <summary>
/// Sticky scoreboard roster: enroll matching side keys in radius, then count every
/// tracked member that is still alive until the scene grace ends.

View file

@ -18,6 +18,12 @@ public sealed class LiveSceneStickyState
public bool HasPresentedScale;
public EnsembleScale PresentedScale;
public float ScaleChangeSince = -999f;
/// <summary>
/// After a camp hits 0, stay in mop-up until the thin side recovers to
/// <see cref="StickyScoreboard.WipeRecoverMin"/> (stops 0↔1 Mass↔Battle thrash).
/// </summary>
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;

View file

@ -11,6 +11,8 @@ namespace IdleSpectator;
public static class StickyScoreboard
{
public const float ScaleHoldSeconds = 2.5f;
/// <summary>Thin camp must reach this alive count to leave mop-up pack framing.</summary>
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);
}
/// <summary>
/// Presentation counts for combat tips: mop-up forces a thin recovering camp to 0
/// until it reaches <see cref="WipeRecoverMin"/>.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>True when sticky kingdom pair matches a stored crisis theater.</summary>
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<string>(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<string> 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,

View file

@ -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
};
}
/// <summary>
/// Idle evil mages must win ambient fill over civ chores (soak: mages never focused).
/// Also gates mage drop powers as camera Signal.
/// </summary>
private static List<HarnessCommand> SpectacleMageFocus()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Combat tip subject must match focus; death handoff rewrites tip; cold combat clears fight tip.
/// </summary>
@ -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
}
/// <summary>
/// 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.
/// </summary>
private static List<HarnessCommand> StoryCrisisWar()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// EarthquakeActive disaster opens a crisis chapter; forced closer is epilogue_crisis.
/// Stacked storm tips during disaster cool must not open a second chapter.
/// </summary>
private static List<HarnessCommand> StoryCrisisDisaster()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Living theater partner must not mint survivor "stands over" prose
/// (soak: Norron vs Nerari attack gaps).
/// </summary>
private static List<HarnessCommand> 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"),

View file

@ -179,8 +179,9 @@ public sealed class InterestCandidate
/// <summary>
/// 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 <paramref name="replacePartner"/>).
/// 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 <paramref name="replacePartner"/>). Lookup misses never unlock the partner.
/// </summary>
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;

View file

@ -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;
}
/// <summary>
/// Pair-locked / Duel tips stay hot only while the owned cast fights - not nearby strangers.
/// </summary>
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<long> 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())

View file

@ -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;
}
/// <summary>
@ -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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// When the tip is (or collapses to) a NamedPair Duel, keep the locked living partner
/// instead of adopting the owner's latest attack_target.
/// </summary>
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
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// True when a scanner battle belongs to the current WarFront kingdom pair
/// (kingdom keys on the battle follow / related, or proximity inside war theater).
/// </summary>
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);
}
/// <summary>Harness: apply a synthetic combat ensemble onto the current scene.</summary>
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;
}
/// <summary>Harness: force one combat focus maintenance pass.</summary>
@ -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;
}
/// <summary>
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
/// </summary>
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;
}
/// <summary>
/// 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))
{

View file

@ -42,6 +42,34 @@ public class StoryWeights
public float ledgerBleedVillage = 0.82f;
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
public float familyLifeBeatCooldownSeconds = 90f;
// --- Crisis chapters (Phase 4) ---
/// <summary>WarFront needs at least this many participants to open a crisis chapter.</summary>
public int crisisWarParticipantMin = 8;
/// <summary>Disaster / outbreak EventStrength floor to open a crisis chapter.</summary>
public float crisisDisasterStrengthMin = 95f;
/// <summary>Score boost for tips that belong to the live crisis.</summary>
public float crisisOwnershipBoost = 22f;
/// <summary>Extra cut-in margin while watching a crisis tip against unrelated peers.</summary>
public float crisisHoldMargin = 40f;
/// <summary>Keep the chapter after the last crisis signal for this many seconds.</summary>
public float crisisLingerSeconds = 10f;
/// <summary>
/// Disaster / outbreak linger after the last WorldLog pulse (tornado tips are short FixedDwell).
/// </summary>
public float crisisDisasterLingerSeconds = 28f;
/// <summary>Hard cap on crisis Active phase length (seconds).</summary>
public float crisisMaxSeconds = 180f;
/// <summary>Do not reopen a crisis for this many seconds after Done.</summary>
public float crisisCooldownSeconds = 45f;
/// <summary>
/// Longer cool after disaster/outbreak closers so stacked storm tips cannot double-close.
/// </summary>
public float crisisDisasterCooldownSeconds = 120f;
/// <summary>Forced closer strength when a crisis chapter ends.</summary>
public float crisisEpilogueStrength = 52f;
/// <summary>Forced closer max watch (seconds).</summary>
public float crisisEpilogueMaxWatch = 16f;
}
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
@ -152,6 +180,15 @@ public class ScoringWeights
public float scannerHotBonus = 18f;
public float scannerWarmBonus = 8f;
public float scannerSpectacleBonus = 12f;
/// <summary>
/// Idle spectacles (evil mage pacing, dragon roosting) keep at least this action score
/// so warm civic chores cannot own ambient forever.
/// </summary>
public float scannerSpectacleIdleFloor = 48f;
/// <summary>
/// Prefer a live spectacle over a non-spectacle ambient pick when within this score gap.
/// </summary>
public float scannerSpectaclePreferMargin = 28f;
public float scannerActionKillsFactor = 0.2f;
public float scannerActionKillsCap = 10f;
public float scannerWarriorBonus = 6f;

View file

@ -0,0 +1,50 @@
namespace IdleSpectator;
/// <summary>Parallel multi-minute chapter over war / disaster / outbreak (not a short StoryArc).</summary>
public enum CrisisKind
{
None = 0,
War = 1,
Disaster = 2,
Outbreak = 3
}
public enum CrisisPhase
{
Active = 0,
Epilogue = 1,
Done = 2
}
/// <summary>
/// Long bias overlay: ownership boost, ambient suppress, forced closer.
/// Lives beside short <see cref="StoryArc"/> so unrelated tips do not thrash chapter state.
/// </summary>
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;
/// <summary>Unordered kingdom theater pair for war chapters (matches local Mass scraps).</summary>
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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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";

View file

@ -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

View file

@ -11,13 +11,17 @@ namespace IdleSpectator;
/// </summary>
public static class WorldActivityScanner
{
private static readonly HashSet<string> SpectacleAssets = new HashSet<string>
/// <summary>Seed ids - live actor_library discovery extends this each refresh.</summary>
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<string> _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);
}
/// <summary>
/// Live spectacle actor / power id class (mages, dragons, demons, …).
/// Seed + token heuristics + live <c>actor_library</c> discovery.
/// </summary>
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<string> live = EnsureSpectacleAssetIds();
if (live.Contains(id))
{
return true;
}
// Loose match for variant ids.
foreach (string key in SpectacleAssets)
return MatchesSpectacleToken(id);
}
private static HashSet<string> EnsureSpectacleAssetIds()
{
float now = Time.unscaledTime;
if (_spectacleAssetIds != null && now - _spectacleIdsCachedAt < SpectacleIdsCacheSeconds)
{
if (id.Contains(key))
return _spectacleAssetIds;
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
{
set.Add(SpectacleAssetSeed[i]);
}
List<string> 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;
}
/// <summary>Best living spectacle unit for ambient ranking (mages, dragons, …).</summary>
private static InterestEvent BuildBestSpectacleUnit()
{
Actor best = null;
float bestScore = float.MinValue;
Dictionary<string, int> 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<string, int> speciesCounts)
{
if (actor?.asset == null)

View file

@ -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";
}

View file

@ -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": [

View file

@ -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"
}

View file

@ -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": [

View file

@ -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 |

View file

@ -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