diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 59bf56f..2a64931 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2436,7 +2436,9 @@ public static class AgentHarness
out float charSig,
out float maxWatch,
out float ttl,
- out bool resumable);
+ out bool resumable,
+ out int participants,
+ out int notables);
InterestLeadKind lead = leadOpt
?? (tier >= InterestTier.Curiosity && tier < InterestTier.Action
@@ -2483,11 +2485,29 @@ public static class AgentHarness
return;
}
+ if (participants > 0 || notables > 0)
+ {
+ c.ParticipantCount = participants;
+ c.NotableParticipantCount = notables;
+ if (participants >= 2 || c.AssetId == "live_battle")
+ {
+ c.Category = "Combat";
+ c.Verb = "fighting";
+ // Keep FixedDwell/Manual for harness injects - units are not actually fighting.
+ if (!force)
+ {
+ c.Completion = InterestCompletionKind.FixedDwell;
+ }
+ }
+
+ InterestScoring.RecalcTotal(c);
+ }
+
_cmdOk++;
Emit(
cmd,
ok: true,
- detail: $"injected key={c.Key} tier={c.Urgency} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} pending={InterestRegistry.PendingCount}");
+ detail: $"injected key={c.Key} tier={c.Urgency} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} score={c.TotalScore:0.#} pending={InterestRegistry.PendingCount}");
}
private static void ParseInterestInjectOptions(
@@ -2498,7 +2518,9 @@ public static class AgentHarness
out float charSig,
out float maxWatch,
out float ttl,
- out bool resumable)
+ out bool resumable,
+ out int participants,
+ out int notables)
{
force = false;
lead = null;
@@ -2507,6 +2529,8 @@ public static class AgentHarness
maxWatch = -1f;
ttl = -1f;
resumable = true;
+ participants = 0;
+ notables = 0;
if (string.IsNullOrEmpty(raw))
{
return;
@@ -2584,6 +2608,15 @@ public static class AgentHarness
case "resumable":
resumable = ParseBool(val, true);
break;
+ case "n":
+ case "participants":
+ case "fighters":
+ int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out participants);
+ break;
+ case "notables":
+ case "notable":
+ int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out notables);
+ break;
}
}
}
@@ -3105,7 +3138,23 @@ public static class AgentHarness
bool captionHit = !string.IsNullOrEmpty(needle)
&& caption.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
pass = dossierHit || captionHit;
- detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)}";
+ detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)} reason='{dossier?.ReasonLine}'";
+ break;
+ }
+ case "dossier_not_contains":
+ {
+ string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
+ UnitDossier dossier = WatchCaption.Current;
+ string reason = dossier?.ReasonLine ?? "";
+ string caption = WatchCaption.LastCaptionText ?? "";
+ bool hit = (!string.IsNullOrEmpty(needle) && reason.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ || (!string.IsNullOrEmpty(needle) && caption.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ || (dossier != null && dossier.ContainsIgnoreCase(needle));
+ // Prefer reason-row check: fail only if the reason line (or full dossier) still shows the bad phrase.
+ bool reasonHit = !string.IsNullOrEmpty(needle)
+ && reason.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
+ pass = !reasonHit;
+ detail = $"needle_absent_in_reason='{needle}' reason='{reason}' hit={reasonHit}";
break;
}
case "dossier_task_refresh":
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 4e6bdbd..c8cf3a7 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -35,6 +35,12 @@ internal static class HarnessScenarios
case "director_gaps":
case "director_coverage":
return DirectorGaps();
+ case "watch_reason":
+ case "watch_reason_clarity":
+ return WatchReasonClarity();
+ case "director_action_priority":
+ case "action_priority":
+ return DirectorActionPriority();
case "discovery":
return Discovery();
case "world_log":
@@ -134,6 +140,7 @@ internal static class HarnessScenarios
Nested("reg_director_life", "director_lifecycle"),
Nested("reg_interest_happiness", "interest_happiness"),
Nested("reg_director_gaps", "director_gaps"),
+ Nested("reg_action_priority", "director_action_priority"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"),
@@ -809,6 +816,110 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Watch-reason row must explain why the camera is here (event), not who (title/name).
+ ///
+ private static List WatchReasonClarity()
+ {
+ return new List
+ {
+ Step("wr0", "dismiss_windows"),
+ Step("wr1", "wait_world"),
+ Step("wr2", "set_setting", expect: "enabled", value: "true"),
+ Step("wr3", "set_setting", expect: "show_watch_reasons", value: "true"),
+ Step("wr4", "set_setting", expect: "show_dossier_caption", value: "true"),
+ Step("wr5", "spawn", asset: "human"),
+ Step("wr6", "spectator", value: "off"),
+ Step("wr7", "spectator", value: "on"),
+ Step("wr8", "focus", asset: "human"),
+ Step("wr9", "dossier_clear_job"),
+
+ // Fighting label must surface as Fighting - not "Action · King …".
+ Step("wr10", "interest_force_session", asset: "human", label: "Fighting: Isemward", tier: "Action", expect: "fight_reason"),
+ Step("wr11", "assert", expect: "dossier_contains", value: "Fighting"),
+ Step("wr12", "assert", expect: "dossier_not_contains", value: "Action ·"),
+ Step("wr13", "assert", expect: "tip_contains", value: "Fighting"),
+ Step("wr14", "screenshot", value: "hud-watch-reason-fighting.png"),
+ Step("wr15", "wait", wait: 0.55f),
+
+ // Stale identity label must not become "Action · King Name of Realm".
+ Step("wr20", "interest_force_session", asset: "human", label: "King Isemward of Essionan", tier: "Action", expect: "king_identity"),
+ Step("wr21", "assert", expect: "dossier_not_contains", value: "Action ·"),
+ Step("wr22", "assert", expect: "dossier_not_contains", value: "King Isemward"),
+ Step("wr23", "screenshot", value: "hud-watch-reason-identity.png"),
+ Step("wr24", "wait", wait: 0.55f),
+
+ // WorldLog-style event phrase stays clear.
+ Step("wr30", "interest_force_session", asset: "human", label: "War: Essionan vs North", tier: "Epic", expect: "war_reason"),
+ Step("wr31", "assert", expect: "dossier_contains", value: "War"),
+ Step("wr32", "assert", expect: "dossier_not_contains", value: "Action ·"),
+ Step("wr33", "screenshot", value: "hud-watch-reason-war.png"),
+ Step("wr34", "wait", wait: 0.55f),
+
+ Step("wr90", "assert", expect: "no_bad"),
+ Step("wr99", "snapshot"),
+ };
+ }
+
+ ///
+ /// Actions outrank characters; same action → more important character wins;
+ /// multi-fighter battles outrank anonymous 1v1; notable duels can beat nameless melees.
+ ///
+ private static List DirectorActionPriority()
+ {
+ return new List
+ {
+ Step("ap0", "dismiss_windows"),
+ Step("ap1", "wait_world"),
+ Step("ap2", "set_setting", expect: "enabled", value: "true"),
+ Step("ap3", "fast_timing", value: "true"),
+ Step("ap4", "spawn", asset: "sheep", count: 2),
+ Step("ap5", "spectator", value: "off"),
+ Step("ap6", "spectator", value: "on"),
+ Step("ap7", "focus", asset: "sheep"),
+ Step("ap8", "interest_variety_clear"),
+ Step("ap9", "interest_expire_pending", value: ""),
+
+ // Same action intensity: higher character significance wins.
+ Step("ap10", "interest_inject", asset: "sheep", label: "FightKing", tier: "Action", expect: "fight_king",
+ value: "lead=event;evt=70;char=40;fighters=2;notables=1"),
+ Step("ap11", "interest_inject", asset: "sheep", label: "FightPeasant", tier: "Action", expect: "fight_peasant",
+ value: "lead=event;evt=70;char=5;fighters=2;notables=0"),
+ Step("ap12", "assert", expect: "interest_score_order", value: "fight_king", label: "fight_peasant"),
+
+ // Multi-person anonymous melee beats anonymous 1v1 at same base evt.
+ Step("ap20", "interest_expire_pending", value: ""),
+ Step("ap21", "interest_inject", asset: "sheep", label: "Melee", tier: "Action", expect: "melee_mass",
+ value: "lead=event;evt=55;char=5;fighters=8;notables=0"),
+ Step("ap22", "interest_inject", asset: "sheep", label: "DuelAnon", tier: "Action", expect: "duel_anon",
+ value: "lead=event;evt=55;char=5;fighters=2;notables=0"),
+ Step("ap23", "assert", expect: "interest_score_order", value: "melee_mass", label: "duel_anon"),
+
+ // Notable duel can beat nameless melee.
+ Step("ap30", "interest_expire_pending", value: ""),
+ Step("ap31", "interest_inject", asset: "sheep", label: "MeleeNoName", tier: "Action", expect: "melee_noname",
+ value: "lead=event;evt=55;char=5;fighters=8;notables=0"),
+ Step("ap32", "interest_inject", asset: "sheep", label: "DuelKings", tier: "Action", expect: "duel_kings",
+ value: "lead=event;evt=55;char=40;fighters=2;notables=2"),
+ Step("ap33", "assert", expect: "interest_score_order", value: "duel_kings", label: "melee_noname"),
+
+ // Combat Action cuts celebrity Story vignette after settle.
+ Step("ap40", "interest_expire_pending", value: ""),
+ Step("ap41", "interest_force_session", asset: "sheep", label: "KingStroll", tier: "Story", expect: "king_stroll"),
+ // Mark current as character vignette (not WorldLog story event).
+ Step("ap42", "interest_inject", asset: "sheep", label: "RealFight", tier: "Action", expect: "real_fight",
+ value: "lead=event;evt=90;char=10;fighters=4;notables=1;force=true"),
+ Step("ap43", "age_current", wait: 1.2f),
+ Step("ap44", "director_run", wait: 1.2f),
+ Step("ap45", "assert", expect: "tip_contains", value: "RealFight"),
+ Step("ap46", "assert", expect: "current_tier", value: "Action"),
+
+ Step("ap90", "assert", expect: "no_bad"),
+ Step("ap91", "fast_timing", value: "false"),
+ Step("ap99", "snapshot"),
+ };
+ }
+
private static List Discovery()
{
return new List
diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs
index 40a5bcb..d724ff8 100644
--- a/IdleSpectator/InterestCandidate.cs
+++ b/IdleSpectator/InterestCandidate.cs
@@ -43,6 +43,10 @@ public sealed class InterestCandidate
public float Novelty = 1f;
public float VisualConfidence = 0.5f;
public float TotalScore;
+ /// Live fighters / participants in a cluster (battles, multi-unit events).
+ public int ParticipantCount;
+ /// Notable (king/favorite/leader/high renown) participants in the cluster.
+ public int NotableParticipantCount;
public Vector3 Position;
public Actor FollowUnit;
public Actor RelatedUnit;
@@ -103,6 +107,8 @@ public sealed class InterestCandidate
Novelty = Novelty,
VisualConfidence = VisualConfidence,
TotalScore = TotalScore,
+ ParticipantCount = ParticipantCount,
+ NotableParticipantCount = NotableParticipantCount,
Position = Position,
FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit,
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 89a32cd..df37d22 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -621,12 +621,29 @@ public static class InterestDirector
if (protectedScene && cur >= InterestTier.Action)
{
- // Action/Story protected: only Epic after settle grace.
+ // Epic always may cut in after settle.
if (next >= InterestTier.Epic && onCurrent >= _settleGrace)
{
return true;
}
+ // Live combat / battles beat celebrity Story vignettes (actions > characters).
+ if (InterestScoring.IsCombatAction(candidate)
+ && InterestScoring.IsCharacterVignette(_current)
+ && onCurrent >= _settleGrace)
+ {
+ return true;
+ }
+
+ // Stronger Action combat may cut a weaker non-combat Action after settle.
+ if (next >= InterestTier.Action
+ && InterestScoring.IsCombatAction(candidate)
+ && !InterestScoring.IsCombatAction(_current)
+ && onCurrent >= _settleGrace)
+ {
+ return true;
+ }
+
return false;
}
diff --git a/IdleSpectator/InterestEvent.cs b/IdleSpectator/InterestEvent.cs
index cb8ac39..6821ea5 100644
--- a/IdleSpectator/InterestEvent.cs
+++ b/IdleSpectator/InterestEvent.cs
@@ -11,6 +11,9 @@ public sealed class InterestEvent
public string Label;
public float CreatedAt;
public string AssetId;
+ public int ParticipantCount;
+ public int NotableParticipantCount;
+ public float CharacterSignificance;
public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive();
diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs
index 23d8411..a7e9e30 100644
--- a/IdleSpectator/InterestFeeds.cs
+++ b/IdleSpectator/InterestFeeds.cs
@@ -172,10 +172,12 @@ public static class InterestFeeds
Key = key,
Urgency = interest.Tier,
LeadKind = lead,
- Category = CategoryForTier(interest.Tier, asset),
+ Category = asset == "live_battle" ? "Combat" : CategoryForTier(interest.Tier, asset),
Source = "direct",
EventStrength = interest.Score > 0f ? interest.Score : 40f + (int)interest.Tier * 15f,
- CharacterSignificance = lead == InterestLeadKind.CharacterLed ? interest.Score : 0f,
+ CharacterSignificance = interest.CharacterSignificance > 0f
+ ? interest.CharacterSignificance
+ : (lead == InterestLeadKind.CharacterLed ? interest.Score * 0.5f : 0f),
VisualConfidence = unit != null ? 0.75f : 0.4f,
Position = interest.Position,
FollowUnit = unit,
@@ -183,6 +185,9 @@ public static class InterestFeeds
Label = interest.Label ?? "",
AssetId = asset,
SpeciesId = unit?.asset != null ? unit.asset.id : asset,
+ ParticipantCount = interest.ParticipantCount,
+ NotableParticipantCount = interest.NotableParticipantCount,
+ Verb = asset == "live_battle" ? "fighting" : "",
CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 30f,
diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs
index 693d18d..55cfd94 100644
--- a/IdleSpectator/InterestScoring.cs
+++ b/IdleSpectator/InterestScoring.cs
@@ -140,25 +140,46 @@ public static class InterestScoring
return;
}
- float urgencyWeight = 1f + (int)c.Urgency * 0.15f;
- if (c.LeadKind == InterestLeadKind.EventLed)
+ // Action-primary: event strength dominates. Character is a tie-break / small amplifier.
+ // Scale / notables on clusters (battles) add to event strength before this runs.
+ float charWeight = c.LeadKind == InterestLeadKind.EventLed ? 0.18f : 0.55f;
+ float urgencyWeight = 1f + (int)c.Urgency * 0.08f;
+ float scaleBonus = 0f;
+ int fighters = c.ParticipantCount;
+ int notables = c.NotableParticipantCount;
+ if (fighters >= 4)
{
- c.TotalScore = c.EventStrength * urgencyWeight
- + c.CharacterSignificance * 0.35f
- + c.VisualConfidence * 20f
- + c.Novelty * 5f;
- c.ScoreDetail =
- $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} vis={c.VisualConfidence:0.##} nov={c.Novelty:0.##}";
+ // Multi-person melees outrank thin anonymous 1v1s.
+ scaleBonus += 28f + Mathf.Min(24f, (fighters - 4) * 4f);
}
- else
+ else if (fighters > 0 && fighters <= 2)
{
- c.TotalScore = c.CharacterSignificance * urgencyWeight
- + c.EventStrength * 0.25f
- + c.VisualConfidence * 12f
- + c.Novelty * 5f;
- c.ScoreDetail =
- $"char={c.CharacterSignificance:0.#} evt={c.EventStrength:0.#} vis={c.VisualConfidence:0.##} nov={c.Novelty:0.##}";
+ if (notables >= 2)
+ {
+ // Extremely important duel (kings/favorites) can beat nameless melees.
+ scaleBonus += 55f;
+ }
+ else if (notables == 1)
+ {
+ scaleBonus += 15f;
+ }
+ else
+ {
+ scaleBonus -= 12f;
+ }
}
+
+ if (notables > 0 && fighters >= 3)
+ {
+ scaleBonus += Mathf.Min(35f, notables * 12f);
+ }
+
+ c.TotalScore = (c.EventStrength + scaleBonus) * urgencyWeight
+ + c.CharacterSignificance * charWeight
+ + c.VisualConfidence * 12f
+ + c.Novelty * 4f;
+ c.ScoreDetail =
+ $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
}
public static InterestMetadataSnapshot GetOrBuildMeta(Actor actor, float now)
@@ -308,6 +329,118 @@ public static class InterestScoring
// ignore
}
+ try
+ {
+ int kills = actor.data != null ? actor.data.kills : 0;
+ if (kills >= 40 || actor.renown >= 200f)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return false;
+ }
+
+ /// King, favorite, or very high renown/kills - can make a 1v1 outrank a no-name melee.
+ public static bool IsExtremelyNotable(Actor actor)
+ {
+ if (actor == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ if (actor.isFavorite() || actor.isKing())
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ try
+ {
+ int kills = actor.data != null ? actor.data.kills : 0;
+ return kills >= 80 || actor.renown >= 400f;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// True when this candidate is a live fight / battle cluster (not celebrity vignette).
+ public static bool IsCombatAction(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return false;
+ }
+
+ if (c.AssetId == "live_battle" || c.Category == "Combat")
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrEmpty(c.Verb)
+ && c.Verb.IndexOf("fight", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+
+ if (c.Completion == InterestCompletionKind.CombatActive)
+ {
+ return true;
+ }
+
+ if (c.FollowUnit != null)
+ {
+ try
+ {
+ if (c.FollowUnit.has_attack_target)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ return false;
+ }
+
+ /// Celebrity / role vignette without a live action beat.
+ public static bool IsCharacterVignette(InterestCandidate c)
+ {
+ if (c == null || IsCombatAction(c))
+ {
+ return false;
+ }
+
+ if (c.LeadKind == InterestLeadKind.CharacterLed
+ || c.Category == "CharacterVignette"
+ || c.Completion == InterestCompletionKind.CharacterVignette)
+ {
+ return true;
+ }
+
+ // Scanner/harness "Story" celebrity strolls are not WorldLog events - actions may cut them.
+ if (c.Urgency == InterestTier.Story
+ && (c.Source == "scanner" || c.Source == "harness")
+ && string.IsNullOrEmpty(c.HappinessEffectId))
+ {
+ return true;
+ }
+
return false;
}
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index 3947946..ae9e7f4 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -214,49 +214,231 @@ public sealed class UnitDossier
private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor,
Dictionary speciesCounts)
{
- string tierPart = watchTier.HasValue ? watchTier.Value.ToString() : "";
- string why = "";
- if (!string.IsNullOrEmpty(HarnessReasonOverride))
- {
- why = HarnessReasonOverride.Trim();
- }
- else if (!string.IsNullOrEmpty(watchLabel))
- {
- why = ShortenWatchLabel(watchLabel, d);
- }
- else
- {
- why = ShortenWatchLabel(
- WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d);
- // Ambient focus: FormatUnitLabel often equals the headline ("Name (species)").
- if (IsRedundantWithHeadline(why, d))
- {
- why = FirstFlavorTag(d);
- }
- }
-
- if (IsRedundantWithHeadline(why, d) || IsShownTraitName(d, why))
- {
- why = "";
- }
+ string why = ResolveWatchWhy(d, watchLabel, actor, speciesCounts);
+ string badge = WatchBadge(watchTier, why);
string watchReason;
- if (string.IsNullOrEmpty(tierPart))
+ if (string.IsNullOrEmpty(badge))
{
watchReason = why ?? "";
}
- else if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0)
+ else if (string.IsNullOrEmpty(why)
+ || why.IndexOf(badge, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
- watchReason = IsShownTraitName(d, tierPart) ? "" : tierPart;
+ watchReason = badge;
}
else
{
- watchReason = tierPart + " · " + why;
+ watchReason = badge + " · " + why;
}
return ComposeReasonWithJob(watchReason, d.JobLabel);
}
+ ///
+ /// Prefer a concrete event/activity phrase over identity ("King Name of X").
+ /// Nametag already shows who; this row should answer why the camera is here.
+ ///
+ private static string ResolveWatchWhy(
+ UnitDossier d,
+ string watchLabel,
+ Actor actor,
+ Dictionary speciesCounts)
+ {
+ if (!string.IsNullOrEmpty(HarnessReasonOverride))
+ {
+ return HarnessReasonOverride.Trim();
+ }
+
+ // Live state beats a stale identity label (fighting king, burning favorite, …).
+ if (d.IsFighting)
+ {
+ return "Fighting";
+ }
+
+ if (d.IsSpectacle)
+ {
+ return "Spectacle";
+ }
+
+ InterestCandidate scene = InterestDirector.CurrentCandidate;
+ if (scene != null
+ && (scene.FollowUnit == actor
+ || (!string.IsNullOrEmpty(scene.Label)
+ && actor != null
+ && scene.SubjectId == d.UnitId)))
+ {
+ string fromScene = WhyFromCandidate(scene, d);
+ if (!string.IsNullOrEmpty(fromScene))
+ {
+ return fromScene;
+ }
+ }
+
+ string fromLabel = ShortenWatchLabel(watchLabel, d);
+ if (!string.IsNullOrEmpty(fromLabel) && !IsIdentityWhy(fromLabel, d))
+ {
+ return fromLabel;
+ }
+
+ string flavor = FirstFlavorTag(d);
+ if (!string.IsNullOrEmpty(flavor))
+ {
+ return flavor;
+ }
+
+ if (!string.IsNullOrEmpty(fromLabel))
+ {
+ return fromLabel;
+ }
+
+ string fallback = ShortenWatchLabel(
+ WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d);
+ if (IsRedundantWithHeadline(fallback, d) || IsIdentityWhy(fallback, d))
+ {
+ return "";
+ }
+
+ return fallback ?? "";
+ }
+
+ private static string WhyFromCandidate(InterestCandidate scene, UnitDossier d)
+ {
+ if (scene == null)
+ {
+ return "";
+ }
+
+ if (!string.IsNullOrEmpty(scene.HappinessEffectId))
+ {
+ if (scene.HappinessEffectId.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase))
+ {
+ string grief = ShortenWatchLabel(scene.Label, d);
+ if (!string.IsNullOrEmpty(grief) && !IsIdentityWhy(grief, d))
+ {
+ return grief;
+ }
+
+ return "Grief";
+ }
+
+ string happy = ShortenWatchLabel(scene.Label, d);
+ if (!string.IsNullOrEmpty(happy) && !IsIdentityWhy(happy, d))
+ {
+ return happy;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(scene.Verb)
+ && scene.Verb.IndexOf("fight", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return "Fighting";
+ }
+
+ if (!string.IsNullOrEmpty(scene.StatusId))
+ {
+ return Humanize(scene.StatusId);
+ }
+
+ string shortened = ShortenWatchLabel(scene.Label, d);
+ if (!string.IsNullOrEmpty(shortened) && !IsIdentityWhy(shortened, d))
+ {
+ return shortened;
+ }
+
+ if (!string.IsNullOrEmpty(scene.Category)
+ && !scene.Category.Equals("CharacterVignette", System.StringComparison.OrdinalIgnoreCase)
+ && !scene.Category.Equals("Settlement", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return Humanize(scene.Category);
+ }
+
+ return "";
+ }
+
+ private static bool IsIdentityWhy(string text, UnitDossier d)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return true;
+ }
+
+ string t = text.Trim();
+ if (t.StartsWith("King ", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("Leader ", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("Leader of ", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // Pure title leftovers after ShortenWatchLabel ("King", "Leader", "Favorite").
+ if (t.Equals("King", System.StringComparison.OrdinalIgnoreCase)
+ || t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase)
+ || t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (d != null
+ && !string.IsNullOrEmpty(d.Name)
+ && t.IndexOf(d.Name, System.StringComparison.OrdinalIgnoreCase) >= 0
+ && t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) < 0
+ && t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0
+ && t.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) < 0
+ && t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ // "Isemward (human, 12 kills)" style still useful via kills - keep if kills mentioned.
+ if (t.IndexOf("kill", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ return IsRedundantWithHeadline(t, d);
+ }
+
+ ///
+ /// Soft urgency badge. Skip when the why already carries the meaning (Fighting, War, …).
+ ///
+ private static string WatchBadge(InterestTier? watchTier, string why)
+ {
+ if (!watchTier.HasValue)
+ {
+ return "";
+ }
+
+ string whySafe = why ?? "";
+ bool whyIsEvent = whySafe.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("Grief", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("Spectacle", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("In action", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || whySafe.IndexOf("Busy", System.StringComparison.OrdinalIgnoreCase) >= 0;
+
+ switch (watchTier.Value)
+ {
+ case InterestTier.Epic:
+ return whyIsEvent && whySafe.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
+ ? ""
+ : "World";
+ case InterestTier.Story:
+ return whyIsEvent ? "" : "Story";
+ case InterestTier.Action:
+ // "Action · …" was jargon; clear event phrases stand alone.
+ return whyIsEvent ? "" : "Live";
+ case InterestTier.Curiosity:
+ return whyIsEvent ? "" : "Curious";
+ default:
+ return "";
+ }
+ }
+
/// Watch reason + job on the dossier row; job alone when reason is empty.
private static string ComposeReasonWithJob(string watchReason, string jobLabel)
{
@@ -283,6 +465,31 @@ public sealed class UnitDossier
private static string FirstFlavorTag(UnitDossier d)
{
+ // Prefer activity/event tags over identity (king/leader/favorite).
+ string[] prefer =
+ {
+ "fighting", "spectacle", "lone of kind", "warrior",
+ "100+ kills", "50+ kills", "10+ kills"
+ };
+ for (int p = 0; p < prefer.Length; p++)
+ {
+ for (int i = 0; i < d.ScoreReasons.Count; i++)
+ {
+ if (!string.Equals(d.ScoreReasons[i], prefer[p], System.StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ string reason = Humanize(d.ScoreReasons[i]);
+ if (!string.IsNullOrEmpty(reason)
+ && !IsRedundantWithHeadline(reason, d)
+ && !IsShownTraitName(d, reason))
+ {
+ return reason;
+ }
+ }
+ }
+
for (int i = 0; i < d.ScoreReasons.Count; i++)
{
string reason = Humanize(d.ScoreReasons[i]);
@@ -302,6 +509,26 @@ public sealed class UnitDossier
continue;
}
+ // Identity alone is weak when nametag already shows the unit.
+ if (reason.Equals("king", System.StringComparison.OrdinalIgnoreCase)
+ || reason.Equals("leader", System.StringComparison.OrdinalIgnoreCase)
+ || reason.Equals("favorite", System.StringComparison.OrdinalIgnoreCase))
+ {
+ if (!string.IsNullOrEmpty(d.KingdomName)
+ && reason.Equals("king", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return "King of " + d.KingdomName;
+ }
+
+ if (!string.IsNullOrEmpty(d.CityName)
+ && reason.Equals("leader", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return "Leader of " + d.CityName;
+ }
+
+ continue;
+ }
+
return reason;
}
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index 2eab86f..a28484e 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -87,25 +87,27 @@ public static class WorldActivityScanner
for (int i = 0; i < n; i++)
{
Actor actor = alive[i];
- float score = ScoreActor(actor, speciesCounts);
ActivityBand band = ActivityInterestTable.Classify(actor);
InterestTier tier = InterestTier.Ambient;
InterestLeadKind lead = InterestLeadKind.CharacterLed;
+ // Actions outrank characters: combat/hot work are Action events; kings walking stay vignettes.
if (band == ActivityBand.Hot || actor.has_attack_target)
{
tier = InterestTier.Action;
lead = InterestLeadKind.EventLed;
}
- else if (band >= ActivityBand.Warm && (actor.isFavorite() || actor.isKing() || actor.isCityLeader()))
+ else if (band >= ActivityBand.Warm)
{
- tier = InterestTier.Story;
+ tier = InterestTier.Action;
lead = InterestLeadKind.EventLed;
}
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
{
tier = InterestTier.Curiosity;
+ lead = InterestLeadKind.CharacterLed;
}
+ SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
long id = 0;
try
{
@@ -120,32 +122,58 @@ public static class WorldActivityScanner
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
: "scan:" + tier + ":" + id;
+ string label = FormatUnitLabel(actor, speciesCounts);
+ if (actor.has_attack_target)
+ {
+ label = "Fighting: " + SafeName(actor);
+ }
+ else if (band == ActivityBand.Hot)
+ {
+ label = "In action";
+ }
+
+ bool combat = actor.has_attack_target || band == ActivityBand.Hot;
+ int fighters = 1;
+ int notables = InterestScoring.IsNotable(actor) ? 1 : 0;
+ if (combat)
+ {
+ CountFightCluster(actor.current_position, 10f, out fighters, out notables, out _);
+ fighters = Mathf.Max(1, fighters);
+ }
+
var candidate = new InterestCandidate
{
Key = key,
Urgency = tier,
LeadKind = lead,
- Category = lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Combat",
+ Category = combat
+ ? "Combat"
+ : (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
Source = "scanner",
- EventStrength = lead == InterestLeadKind.EventLed ? score : score * 0.3f,
- CharacterSignificance = score,
+ EventStrength = lead == InterestLeadKind.EventLed ? actionScore : actionScore * 0.35f,
+ CharacterSignificance = charScore,
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
- Label = FormatUnitLabel(actor, speciesCounts),
+ Label = label,
+ Verb = actor.has_attack_target ? "fighting" : (band == ActivityBand.Hot ? "busy" : ""),
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
+ ParticipantCount = combat ? fighters : 0,
+ NotableParticipantCount = combat ? notables : 0,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f,
MaxWatch = 22f,
- Completion = lead == InterestLeadKind.EventLed
+ Completion = combat
? InterestCompletionKind.CombatActive
- : InterestCompletionKind.CharacterVignette
+ : (lead == InterestLeadKind.EventLed
+ ? InterestCompletionKind.ActivityActive
+ : InterestCompletionKind.CharacterVignette)
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
@@ -167,15 +195,38 @@ public static class WorldActivityScanner
{
_cachedBattle = BuildHottestBattle();
InterestEvent bestUnit = BuildBestScoredUnit();
+ _cachedBest = PreferRicherAction(_cachedBattle, bestUnit);
+ }
- // Prefer an active battle when it is meaningfully hotter than quiet unit watching.
- if (_cachedBattle != null)
+ ///
+ /// Pick battle vs unit dynamically: mass fights win unless a notable 1v1 outranks an anonymous melee.
+ ///
+ private static InterestEvent PreferRicherAction(InterestEvent battle, InterestEvent unit)
+ {
+ if (battle == null)
{
- _cachedBest = _cachedBattle;
- return;
+ return unit;
}
- _cachedBest = bestUnit;
+ if (unit == null)
+ {
+ return battle;
+ }
+
+ bool unitDuel = unit.FollowUnit != null
+ && unit.FollowUnit.has_attack_target
+ && unit.ParticipantCount > 0
+ && unit.ParticipantCount <= 2
+ && unit.NotableParticipantCount >= 2;
+ bool battleAnonymousMelee = battle.ParticipantCount >= 4
+ && battle.NotableParticipantCount == 0;
+
+ if (unitDuel && battleAnonymousMelee && unit.Score + 15f >= battle.Score)
+ {
+ return unit;
+ }
+
+ return battle.Score >= unit.Score ? battle : unit;
}
private static InterestEvent BuildHottestBattle()
@@ -183,11 +234,12 @@ public static class WorldActivityScanner
HashSet battles = BattleKeeperManager.get();
if (battles == null || battles.Count == 0)
{
- return null;
+ // Fall through to live fight clusters with no BattleKeeper entry yet.
+ return BuildHottestFightCluster();
}
- BattleContainer best = null;
- int bestDeaths = 0;
+ InterestEvent best = null;
+ float bestScore = float.MinValue;
foreach (BattleContainer battle in battles)
{
if (battle?.tile == null)
@@ -196,30 +248,213 @@ public static class WorldActivityScanner
}
int deaths = battle.getDeathsTotal();
- if (deaths > bestDeaths)
+ Vector3 pos = battle.tile.posV3;
+ CountFightCluster(pos, 14f, out int fighters, out int notables, out Actor follow);
+ if (deaths < 1 && fighters < 2)
{
- bestDeaths = deaths;
- best = battle;
+ continue;
+ }
+
+ fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0);
+ float score = ScoreFightCluster(deaths, fighters, notables);
+ if (score > bestScore)
+ {
+ bestScore = score;
+ string label = fighters >= 5 || deaths >= 6
+ ? $"Battle ({fighters} fighting, {deaths} fallen)"
+ : fighters <= 2
+ ? $"Duel ({deaths} fallen)"
+ : $"Skirmish ({fighters} fighting)";
+ best = new InterestEvent
+ {
+ Tier = InterestTier.Action,
+ Score = score,
+ Position = pos,
+ FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
+ Label = label,
+ CreatedAt = Time.unscaledTime,
+ AssetId = "live_battle",
+ ParticipantCount = fighters,
+ NotableParticipantCount = notables,
+ CharacterSignificance = notables * 18f
+ };
}
}
- if (best == null || bestDeaths < 1)
+ InterestEvent live = BuildHottestFightCluster();
+ return PreferRicherAction(best, live);
+ }
+
+ /// Cluster of units currently in combat (even before BattleKeeper records deaths).
+ private static InterestEvent BuildHottestFightCluster()
+ {
+ InterestEvent best = null;
+ float bestScore = float.MinValue;
+ var seen = new HashSet();
+ foreach (Actor actor in EnumerateAliveUnits())
{
- return null;
+ if (actor == null || !actor.isAlive() || !actor.has_attack_target)
+ {
+ continue;
+ }
+
+ long id = 0;
+ try
+ {
+ id = actor.getID();
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (seen.Contains(id))
+ {
+ continue;
+ }
+
+ CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out Actor follow);
+ if (fighters < 1)
+ {
+ continue;
+ }
+
+ // Mark cluster members so we don't emit N duplicates.
+ MarkClusterSeen(actor.current_position, 10f, seen);
+ float score = ScoreFightCluster(deaths: 0, fighters, notables);
+ if (score > bestScore)
+ {
+ bestScore = score;
+ best = new InterestEvent
+ {
+ Tier = InterestTier.Action,
+ Score = score,
+ Position = actor.current_position,
+ FollowUnit = follow ?? actor,
+ Label = fighters <= 2 ? "Duel" : $"Fight ({fighters})",
+ CreatedAt = Time.unscaledTime,
+ AssetId = "live_battle",
+ ParticipantCount = fighters,
+ NotableParticipantCount = notables,
+ CharacterSignificance = notables * 18f
+ };
+ }
}
- Vector3 pos = best.tile.posV3;
- Actor nearby = FindNearestAliveUnit(pos, 14f);
- return new InterestEvent
+ return best;
+ }
+
+ private static float ScoreFightCluster(int deaths, int fighters, int notables)
+ {
+ float score = deaths * 10f + Mathf.Max(0, fighters) * 14f + notables * 22f;
+ // Multi-person melees get a clear bump over thin 1v1s.
+ if (fighters >= 4)
{
- Tier = InterestTier.Action,
- Score = 50f + bestDeaths,
- Position = pos,
- FollowUnit = nearby,
- Label = bestDeaths >= 6 ? $"Battle ({bestDeaths} fallen)" : $"Skirmish ({bestDeaths} fallen)",
- CreatedAt = Time.unscaledTime,
- AssetId = "live_battle"
- };
+ score += 28f;
+ }
+ else if (fighters <= 2)
+ {
+ score -= 8f;
+ // Notable duels recover - king vs king / favorites.
+ if (notables >= 2)
+ {
+ score += 45f;
+ }
+ else if (notables == 1)
+ {
+ score += 12f;
+ }
+ }
+
+ return score;
+ }
+
+ private static void CountFightCluster(
+ Vector2 pos,
+ float radius,
+ out int fighters,
+ out int notables,
+ out Actor bestFollow)
+ {
+ fighters = 0;
+ notables = 0;
+ bestFollow = null;
+ float bestChar = -1f;
+ float r2 = radius * radius;
+ foreach (Actor actor in EnumerateAliveUnits())
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ continue;
+ }
+
+ float dx = actor.current_position.x - pos.x;
+ float dy = actor.current_position.y - pos.y;
+ if (dx * dx + dy * dy > r2)
+ {
+ continue;
+ }
+
+ bool fighting = false;
+ try
+ {
+ fighting = actor.has_attack_target
+ || (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat);
+ }
+ catch
+ {
+ fighting = false;
+ }
+
+ if (!fighting)
+ {
+ continue;
+ }
+
+ fighters++;
+ bool notable = InterestScoring.IsNotable(actor);
+ bool extreme = InterestScoring.IsExtremelyNotable(actor);
+ if (notable)
+ {
+ notables++;
+ }
+
+ SplitActorScore(actor, null, out _, out float charScore);
+ float weight = charScore + (extreme ? 30f : 0f);
+ if (weight > bestChar)
+ {
+ bestChar = weight;
+ bestFollow = actor;
+ }
+ }
+ }
+
+ private static void MarkClusterSeen(Vector2 pos, float radius, HashSet seen)
+ {
+ float r2 = radius * radius;
+ foreach (Actor actor in EnumerateAliveUnits())
+ {
+ if (actor == null || !actor.isAlive() || !actor.has_attack_target)
+ {
+ continue;
+ }
+
+ float dx = actor.current_position.x - pos.x;
+ float dy = actor.current_position.y - pos.y;
+ if (dx * dx + dy * dy > r2)
+ {
+ continue;
+ }
+
+ try
+ {
+ seen.Add(actor.getID());
+ }
+ catch
+ {
+ // ignore
+ }
+ }
}
private static InterestEvent BuildBestScoredUnit()
@@ -251,15 +486,15 @@ public static class WorldActivityScanner
for (int i = 0; i < alive.Count; i++)
{
Actor actor = alive[i];
- float score = ScoreActor(actor, speciesCounts);
- if (score > bestScore + 0.01f)
+ float unitScore = ScoreActor(actor, speciesCounts);
+ if (unitScore > bestScore + 0.01f)
{
- bestScore = score;
+ bestScore = unitScore;
best = actor;
nearTies.Clear();
nearTies.Add(actor);
}
- else if (Mathf.Abs(score - bestScore) <= 2f)
+ else if (Mathf.Abs(unitScore - bestScore) <= 2f)
{
nearTies.Add(actor);
}
@@ -278,17 +513,19 @@ public static class WorldActivityScanner
InterestTier tier = InterestTier.Ambient;
ActivityBand band = ActivityInterestTable.Classify(best);
- bool important = best.isFavorite() || best.isKing() || best.isCityLeader();
-
- if (band == ActivityBand.Hot || best.has_attack_target
- || (best.data != null && best.data.kills >= 10 && band >= ActivityBand.Warm))
+ SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
+ int fighters = 0;
+ int notables = 0;
+ if (best.has_attack_target || band == ActivityBand.Hot)
{
+ CountFightCluster(best.current_position, 10f, out fighters, out notables, out _);
+ fighters = Mathf.Max(1, fighters);
tier = InterestTier.Action;
}
- else if (band >= ActivityBand.Warm && important)
+ else if (band >= ActivityBand.Warm)
{
- // Important + interesting (not idle king/favorite).
- tier = InterestTier.Story;
+ // Warm work is an action beat - not Story celebrity.
+ tier = InterestTier.Action;
}
else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts))
{
@@ -299,15 +536,28 @@ public static class WorldActivityScanner
tier = InterestTier.Ambient;
}
+ float finalScore = ScoreFightCluster(0, fighters, notables);
+ if (fighters <= 0)
+ {
+ finalScore = actionScore + charScore * 0.18f;
+ }
+ else
+ {
+ finalScore = Mathf.Max(finalScore, actionScore + charScore * 0.18f);
+ }
+
return new InterestEvent
{
Tier = tier,
- Score = bestScore,
+ Score = finalScore,
Position = best.current_position,
FollowUnit = best,
Label = FormatUnitLabel(best, speciesCounts),
CreatedAt = Time.unscaledTime,
- AssetId = best.asset != null ? best.asset.id : "scored_unit"
+ AssetId = best.asset != null ? best.asset.id : "scored_unit",
+ ParticipantCount = fighters,
+ NotableParticipantCount = notables,
+ CharacterSignificance = charScore
};
}
@@ -390,36 +640,73 @@ public static class WorldActivityScanner
private static float ScoreActor(Actor actor, Dictionary speciesCounts)
{
+ SplitActorScore(actor, speciesCounts, out float action, out float character);
+ // Action-primary total used for ranking; character is a light tie-break.
+ return action + character * 0.18f;
+ }
+
+ ///
+ /// Split live action intensity from who the unit is.
+ /// Same action → higher character wins; character alone never dominates a real fight.
+ ///
+ private static void SplitActorScore(
+ Actor actor,
+ Dictionary speciesCounts,
+ out float actionScore,
+ out float characterScore)
+ {
+ actionScore = 0f;
+ characterScore = 0f;
if (actor == null)
{
- return 0f;
+ return;
}
float activity = ActivityInterestTable.ScoreActivity(actor);
ActivityBand band = ActivityInterestTable.Classify(actor);
-
- float importance = 0f;
- if (actor.isFavorite())
+ actionScore = activity;
+ if (actor.has_attack_target)
{
- importance += 22f;
+ actionScore += 40f;
}
- if (actor.isKing())
+ if (band == ActivityBand.Hot)
{
- importance += 18f;
+ actionScore += 18f;
}
- else if (actor.isCityLeader())
+ else if (band == ActivityBand.Warm)
{
- importance += 14f;
- }
- else if (actor.is_profession_warrior)
- {
- importance += 6f;
+ actionScore += 8f;
}
if (IsSpectacle(actor))
{
- importance += 8f;
+ actionScore += 12f;
+ }
+
+ int kills = actor.data != null ? actor.data.kills : 0;
+ if (band >= ActivityBand.Warm || actor.has_attack_target)
+ {
+ actionScore += Mathf.Min(10f, kills * 0.2f);
+ }
+
+ // Character significance - tie-break / amplifier only.
+ if (actor.isFavorite())
+ {
+ characterScore += 22f;
+ }
+
+ if (actor.isKing())
+ {
+ characterScore += 18f;
+ }
+ else if (actor.isCityLeader())
+ {
+ characterScore += 14f;
+ }
+ else if (actor.is_profession_warrior)
+ {
+ characterScore += 6f;
}
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
@@ -427,47 +714,20 @@ public static class WorldActivityScanner
&& speciesCounts.TryGetValue(speciesId, out int pop)
&& pop == 1)
{
- importance += 10f;
+ characterScore += 8f;
}
- if (actor.subspecies != null)
+ characterScore += Mathf.Min(8f, actor.renown / 120f);
+ characterScore += Mathf.Min(8f, kills * 0.15f);
+
+ // Cold kings barely score - do not let identity monopolize the camera.
+ if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor))
{
- try
- {
- int subCount = CountSubspeciesMembers(actor.subspecies);
- if (subCount > 0 && subCount <= 3)
- {
- importance += 6f;
- }
- }
- catch
- {
- // ignore
- }
+ actionScore *= 0.25f;
+ characterScore *= 0.35f;
}
- importance += Mathf.Min(8f, actor.renown / 120f);
-
- // Importance only amplifies when the unit is actually doing something.
- float score;
- if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
- {
- score = activity + importance * 0.85f;
- }
- else
- {
- // Cold: tiny identity bump so favorites/kings do not monopolize Ambient.
- score = activity + importance * 0.08f;
- }
-
- int kills = actor.data != null ? actor.data.kills : 0;
- if (band >= ActivityBand.Warm)
- {
- score += Mathf.Min(12f, kills * 0.35f);
- }
-
- score += (actor.getID() % 7) * 0.01f;
- return score;
+ actionScore += (actor.getID() % 7) * 0.01f;
}
/// True when the focused unit's current activity is Cold (idle scoring).
@@ -540,23 +800,8 @@ public static class WorldActivityScanner
{
string name = SafeName(actor);
string species = actor.asset != null ? actor.asset.id : "creature";
- if (actor.isFavorite())
- {
- return $"Favorite: {name}";
- }
-
- if (actor.isKing())
- {
- string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm";
- return $"King {name} of {kingdom}";
- }
-
- if (actor.isCityLeader())
- {
- string city = actor.city != null ? actor.city.name : "town";
- return $"Leader {name} of {city}";
- }
+ // Event / activity first - titles alone are not a watch reason (nametag already has the name).
if (actor.has_attack_target)
{
return $"Fighting: {name}";
@@ -567,6 +812,23 @@ public static class WorldActivityScanner
return $"Spectacle: {name} ({species})";
}
+ if (actor.isFavorite())
+ {
+ return $"Favorite: {name}";
+ }
+
+ if (actor.isKing())
+ {
+ string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm";
+ return $"King of {kingdom}";
+ }
+
+ if (actor.isCityLeader())
+ {
+ string city = actor.city != null ? actor.city.name : "town";
+ return $"Leader of {city}";
+ }
+
if (IsSingletonSpecies(actor, speciesCounts))
{
return $"Lone {species}: {name}";
@@ -779,7 +1041,10 @@ public static class WorldActivityScanner
FollowUnit = source.FollowUnit,
Label = source.Label,
CreatedAt = Time.unscaledTime,
- AssetId = source.AssetId
+ AssetId = source.AssetId,
+ ParticipantCount = source.ParticipantCount,
+ NotableParticipantCount = source.NotableParticipantCount,
+ CharacterSignificance = source.CharacterSignificance
};
}
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 84dce33..4ead7e5 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.14.16",
+ "version": "0.14.21",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh
index f86b763..d732db8 100755
--- a/scripts/harness-run.sh
+++ b/scripts/harness-run.sh
@@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
director_lifecycle
interest_happiness
director_gaps
+ director_action_priority
discovery
world_log
chronicle_smoke