reworking scoring

This commit is contained in:
DazedAnon 2026-07-15 16:35:12 -05:00
parent d260472cc4
commit 77cb688925
11 changed files with 982 additions and 165 deletions

View file

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

View file

@ -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
};
}
/// <summary>
/// Watch-reason row must explain why the camera is here (event), not who (title/name).
/// </summary>
private static List<HarnessCommand> WatchReasonClarity()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Actions outrank characters; same action → more important character wins;
/// multi-fighter battles outrank anonymous 1v1; notable duels can beat nameless melees.
/// </summary>
private static List<HarnessCommand> DirectorActionPriority()
{
return new List<HarnessCommand>
{
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<HarnessCommand> Discovery()
{
return new List<HarnessCommand>

View file

@ -43,6 +43,10 @@ public sealed class InterestCandidate
public float Novelty = 1f;
public float VisualConfidence = 0.5f;
public float TotalScore;
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
public int ParticipantCount;
/// <summary>Notable (king/favorite/leader/high renown) participants in the cluster.</summary>
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,

View file

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

View file

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

View file

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

View file

@ -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;
}
/// <summary>King, favorite, or very high renown/kills - can make a 1v1 outrank a no-name melee.</summary>
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;
}
}
/// <summary>True when this candidate is a live fight / battle cluster (not celebrity vignette).</summary>
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;
}
/// <summary>Celebrity / role vignette without a live action beat.</summary>
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;
}

View file

@ -214,49 +214,231 @@ public sealed class UnitDossier
private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor,
Dictionary<string, int> 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);
}
/// <summary>
/// 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.
/// </summary>
private static string ResolveWatchWhy(
UnitDossier d,
string watchLabel,
Actor actor,
Dictionary<string, int> 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);
}
/// <summary>
/// Soft urgency badge. Skip when the why already carries the meaning (Fighting, War, …).
/// </summary>
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 "";
}
}
/// <summary>Watch reason + job on the dossier row; job alone when reason is empty.</summary>
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;
}

View file

@ -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)
/// <summary>
/// Pick battle vs unit dynamically: mass fights win unless a notable 1v1 outranks an anonymous melee.
/// </summary>
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<BattleContainer> 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);
}
/// <summary>Cluster of units currently in combat (even before BattleKeeper records deaths).</summary>
private static InterestEvent BuildHottestFightCluster()
{
InterestEvent best = null;
float bestScore = float.MinValue;
var seen = new HashSet<long>();
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<long> 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<string, int> 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;
}
/// <summary>
/// Split live action intensity from who the unit is.
/// Same action → higher character wins; character alone never dominates a real fight.
/// </summary>
private static void SplitActorScore(
Actor actor,
Dictionary<string, int> 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;
}
/// <summary>True when the focused unit's current activity is Cold (idle scoring).</summary>
@ -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
};
}
}

View file

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

View file

@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
director_lifecycle
interest_happiness
director_gaps
director_action_priority
discovery
world_log
chronicle_smoke