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:
parent
9a059c17dd
commit
c23b2b9c1e
24 changed files with 3643 additions and 265 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
50
IdleSpectator/Story/CrisisChapter.cs
Normal file
50
IdleSpectator/Story/CrisisChapter.cs
Normal 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
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue