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 charSig,
out float maxWatch, out float maxWatch,
out float ttl, out float ttl,
out bool resumable); out bool resumable,
out int participants,
out int notables);
InterestLeadKind lead = leadOpt InterestLeadKind lead = leadOpt
?? (tier >= InterestTier.Curiosity && tier < InterestTier.Action ?? (tier >= InterestTier.Curiosity && tier < InterestTier.Action
@ -2483,11 +2485,29 @@ public static class AgentHarness
return; 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++; _cmdOk++;
Emit( Emit(
cmd, cmd,
ok: true, 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( private static void ParseInterestInjectOptions(
@ -2498,7 +2518,9 @@ public static class AgentHarness
out float charSig, out float charSig,
out float maxWatch, out float maxWatch,
out float ttl, out float ttl,
out bool resumable) out bool resumable,
out int participants,
out int notables)
{ {
force = false; force = false;
lead = null; lead = null;
@ -2507,6 +2529,8 @@ public static class AgentHarness
maxWatch = -1f; maxWatch = -1f;
ttl = -1f; ttl = -1f;
resumable = true; resumable = true;
participants = 0;
notables = 0;
if (string.IsNullOrEmpty(raw)) if (string.IsNullOrEmpty(raw))
{ {
return; return;
@ -2584,6 +2608,15 @@ public static class AgentHarness
case "resumable": case "resumable":
resumable = ParseBool(val, true); resumable = ParseBool(val, true);
break; 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) bool captionHit = !string.IsNullOrEmpty(needle)
&& caption.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; && caption.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
pass = dossierHit || captionHit; 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; break;
} }
case "dossier_task_refresh": case "dossier_task_refresh":

View file

@ -35,6 +35,12 @@ internal static class HarnessScenarios
case "director_gaps": case "director_gaps":
case "director_coverage": case "director_coverage":
return DirectorGaps(); return DirectorGaps();
case "watch_reason":
case "watch_reason_clarity":
return WatchReasonClarity();
case "director_action_priority":
case "action_priority":
return DirectorActionPriority();
case "discovery": case "discovery":
return Discovery(); return Discovery();
case "world_log": case "world_log":
@ -134,6 +140,7 @@ internal static class HarnessScenarios
Nested("reg_director_life", "director_lifecycle"), Nested("reg_director_life", "director_lifecycle"),
Nested("reg_interest_happiness", "interest_happiness"), Nested("reg_interest_happiness", "interest_happiness"),
Nested("reg_director_gaps", "director_gaps"), Nested("reg_director_gaps", "director_gaps"),
Nested("reg_action_priority", "director_action_priority"),
Nested("reg_discovery", "discovery"), Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"), Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"), 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() private static List<HarnessCommand> Discovery()
{ {
return new List<HarnessCommand> return new List<HarnessCommand>

View file

@ -43,6 +43,10 @@ public sealed class InterestCandidate
public float Novelty = 1f; public float Novelty = 1f;
public float VisualConfidence = 0.5f; public float VisualConfidence = 0.5f;
public float TotalScore; 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 Vector3 Position;
public Actor FollowUnit; public Actor FollowUnit;
public Actor RelatedUnit; public Actor RelatedUnit;
@ -103,6 +107,8 @@ public sealed class InterestCandidate
Novelty = Novelty, Novelty = Novelty,
VisualConfidence = VisualConfidence, VisualConfidence = VisualConfidence,
TotalScore = TotalScore, TotalScore = TotalScore,
ParticipantCount = ParticipantCount,
NotableParticipantCount = NotableParticipantCount,
Position = Position, Position = Position,
FollowUnit = FollowUnit, FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit, RelatedUnit = RelatedUnit,

View file

@ -621,12 +621,29 @@ public static class InterestDirector
if (protectedScene && cur >= InterestTier.Action) 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) if (next >= InterestTier.Epic && onCurrent >= _settleGrace)
{ {
return true; 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; return false;
} }

View file

@ -11,6 +11,9 @@ public sealed class InterestEvent
public string Label; public string Label;
public float CreatedAt; public float CreatedAt;
public string AssetId; public string AssetId;
public int ParticipantCount;
public int NotableParticipantCount;
public float CharacterSignificance;
public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive(); public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive();

View file

@ -172,10 +172,12 @@ public static class InterestFeeds
Key = key, Key = key,
Urgency = interest.Tier, Urgency = interest.Tier,
LeadKind = lead, LeadKind = lead,
Category = CategoryForTier(interest.Tier, asset), Category = asset == "live_battle" ? "Combat" : CategoryForTier(interest.Tier, asset),
Source = "direct", Source = "direct",
EventStrength = interest.Score > 0f ? interest.Score : 40f + (int)interest.Tier * 15f, 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, VisualConfidence = unit != null ? 0.75f : 0.4f,
Position = interest.Position, Position = interest.Position,
FollowUnit = unit, FollowUnit = unit,
@ -183,6 +185,9 @@ public static class InterestFeeds
Label = interest.Label ?? "", Label = interest.Label ?? "",
AssetId = asset, AssetId = asset,
SpeciesId = unit?.asset != null ? unit.asset.id : 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, CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime,
LastSeenAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 30f, ExpiresAt = Time.unscaledTime + 30f,

View file

@ -140,25 +140,46 @@ public static class InterestScoring
return; return;
} }
float urgencyWeight = 1f + (int)c.Urgency * 0.15f; // Action-primary: event strength dominates. Character is a tie-break / small amplifier.
if (c.LeadKind == InterestLeadKind.EventLed) // 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 // Multi-person melees outrank thin anonymous 1v1s.
+ c.CharacterSignificance * 0.35f scaleBonus += 28f + Mathf.Min(24f, (fighters - 4) * 4f);
+ c.VisualConfidence * 20f
+ c.Novelty * 5f;
c.ScoreDetail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} vis={c.VisualConfidence:0.##} nov={c.Novelty:0.##}";
} }
else else if (fighters > 0 && fighters <= 2)
{ {
c.TotalScore = c.CharacterSignificance * urgencyWeight if (notables >= 2)
+ c.EventStrength * 0.25f {
+ c.VisualConfidence * 12f // Extremely important duel (kings/favorites) can beat nameless melees.
+ c.Novelty * 5f; scaleBonus += 55f;
c.ScoreDetail = }
$"char={c.CharacterSignificance:0.#} evt={c.EventStrength:0.#} vis={c.VisualConfidence:0.##} nov={c.Novelty:0.##}"; 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) public static InterestMetadataSnapshot GetOrBuildMeta(Actor actor, float now)
@ -308,6 +329,118 @@ public static class InterestScoring
// ignore // 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; return false;
} }

View file

@ -214,49 +214,231 @@ public sealed class UnitDossier
private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor, private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor,
Dictionary<string, int> speciesCounts) Dictionary<string, int> speciesCounts)
{ {
string tierPart = watchTier.HasValue ? watchTier.Value.ToString() : ""; string why = ResolveWatchWhy(d, watchLabel, actor, speciesCounts);
string why = ""; string badge = WatchBadge(watchTier, 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 watchReason; string watchReason;
if (string.IsNullOrEmpty(tierPart)) if (string.IsNullOrEmpty(badge))
{ {
watchReason = why ?? ""; 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 else
{ {
watchReason = tierPart + " · " + why; watchReason = badge + " · " + why;
} }
return ComposeReasonWithJob(watchReason, d.JobLabel); 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> /// <summary>Watch reason + job on the dossier row; job alone when reason is empty.</summary>
private static string ComposeReasonWithJob(string watchReason, string jobLabel) private static string ComposeReasonWithJob(string watchReason, string jobLabel)
{ {
@ -283,6 +465,31 @@ public sealed class UnitDossier
private static string FirstFlavorTag(UnitDossier d) 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++) for (int i = 0; i < d.ScoreReasons.Count; i++)
{ {
string reason = Humanize(d.ScoreReasons[i]); string reason = Humanize(d.ScoreReasons[i]);
@ -302,6 +509,26 @@ public sealed class UnitDossier
continue; 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; return reason;
} }

View file

@ -87,25 +87,27 @@ public static class WorldActivityScanner
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
{ {
Actor actor = alive[i]; Actor actor = alive[i];
float score = ScoreActor(actor, speciesCounts);
ActivityBand band = ActivityInterestTable.Classify(actor); ActivityBand band = ActivityInterestTable.Classify(actor);
InterestTier tier = InterestTier.Ambient; InterestTier tier = InterestTier.Ambient;
InterestLeadKind lead = InterestLeadKind.CharacterLed; 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) if (band == ActivityBand.Hot || actor.has_attack_target)
{ {
tier = InterestTier.Action; tier = InterestTier.Action;
lead = InterestLeadKind.EventLed; 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; lead = InterestLeadKind.EventLed;
} }
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts)) else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
{ {
tier = InterestTier.Curiosity; tier = InterestTier.Curiosity;
lead = InterestLeadKind.CharacterLed;
} }
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
long id = 0; long id = 0;
try try
{ {
@ -120,32 +122,58 @@ public static class WorldActivityScanner
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit") ? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
: "scan:" + tier + ":" + id; : "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 var candidate = new InterestCandidate
{ {
Key = key, Key = key,
Urgency = tier, Urgency = tier,
LeadKind = lead, LeadKind = lead,
Category = lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Combat", Category = combat
? "Combat"
: (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
Source = "scanner", Source = "scanner",
EventStrength = lead == InterestLeadKind.EventLed ? score : score * 0.3f, EventStrength = lead == InterestLeadKind.EventLed ? actionScore : actionScore * 0.35f,
CharacterSignificance = score, CharacterSignificance = charScore,
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f, VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
Position = actor.current_position, Position = actor.current_position,
FollowUnit = actor, FollowUnit = actor,
SubjectId = id, 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", AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
SpeciesId = actor.asset != null ? actor.asset.id : "", SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "", CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "", KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
ParticipantCount = combat ? fighters : 0,
NotableParticipantCount = combat ? notables : 0,
CreatedAt = Time.unscaledTime, CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f, ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f, MinWatch = 2f,
MaxWatch = 22f, MaxWatch = 22f,
Completion = lead == InterestLeadKind.EventLed Completion = combat
? InterestCompletionKind.CombatActive ? InterestCompletionKind.CombatActive
: InterestCompletionKind.CharacterVignette : (lead == InterestLeadKind.EventLed
? InterestCompletionKind.ActivityActive
: InterestCompletionKind.CharacterVignette)
}; };
InterestScoring.ScoreCheap(candidate); InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate); InterestRegistry.Upsert(candidate);
@ -167,15 +195,38 @@ public static class WorldActivityScanner
{ {
_cachedBattle = BuildHottestBattle(); _cachedBattle = BuildHottestBattle();
InterestEvent bestUnit = BuildBestScoredUnit(); InterestEvent bestUnit = BuildBestScoredUnit();
_cachedBest = PreferRicherAction(_cachedBattle, bestUnit);
}
// Prefer an active battle when it is meaningfully hotter than quiet unit watching. /// <summary>
if (_cachedBattle != null) /// 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 unit;
return;
} }
_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() private static InterestEvent BuildHottestBattle()
@ -183,11 +234,12 @@ public static class WorldActivityScanner
HashSet<BattleContainer> battles = BattleKeeperManager.get(); HashSet<BattleContainer> battles = BattleKeeperManager.get();
if (battles == null || battles.Count == 0) if (battles == null || battles.Count == 0)
{ {
return null; // Fall through to live fight clusters with no BattleKeeper entry yet.
return BuildHottestFightCluster();
} }
BattleContainer best = null; InterestEvent best = null;
int bestDeaths = 0; float bestScore = float.MinValue;
foreach (BattleContainer battle in battles) foreach (BattleContainer battle in battles)
{ {
if (battle?.tile == null) if (battle?.tile == null)
@ -196,30 +248,213 @@ public static class WorldActivityScanner
} }
int deaths = battle.getDeathsTotal(); 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; continue;
best = battle; }
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; return best;
Actor nearby = FindNearestAliveUnit(pos, 14f); }
return new InterestEvent
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 += 28f;
Score = 50f + bestDeaths, }
Position = pos, else if (fighters <= 2)
FollowUnit = nearby, {
Label = bestDeaths >= 6 ? $"Battle ({bestDeaths} fallen)" : $"Skirmish ({bestDeaths} fallen)", score -= 8f;
CreatedAt = Time.unscaledTime, // Notable duels recover - king vs king / favorites.
AssetId = "live_battle" 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() private static InterestEvent BuildBestScoredUnit()
@ -251,15 +486,15 @@ public static class WorldActivityScanner
for (int i = 0; i < alive.Count; i++) for (int i = 0; i < alive.Count; i++)
{ {
Actor actor = alive[i]; Actor actor = alive[i];
float score = ScoreActor(actor, speciesCounts); float unitScore = ScoreActor(actor, speciesCounts);
if (score > bestScore + 0.01f) if (unitScore > bestScore + 0.01f)
{ {
bestScore = score; bestScore = unitScore;
best = actor; best = actor;
nearTies.Clear(); nearTies.Clear();
nearTies.Add(actor); nearTies.Add(actor);
} }
else if (Mathf.Abs(score - bestScore) <= 2f) else if (Mathf.Abs(unitScore - bestScore) <= 2f)
{ {
nearTies.Add(actor); nearTies.Add(actor);
} }
@ -278,17 +513,19 @@ public static class WorldActivityScanner
InterestTier tier = InterestTier.Ambient; InterestTier tier = InterestTier.Ambient;
ActivityBand band = ActivityInterestTable.Classify(best); ActivityBand band = ActivityInterestTable.Classify(best);
bool important = best.isFavorite() || best.isKing() || best.isCityLeader(); SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
int fighters = 0;
if (band == ActivityBand.Hot || best.has_attack_target int notables = 0;
|| (best.data != null && best.data.kills >= 10 && band >= ActivityBand.Warm)) 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; tier = InterestTier.Action;
} }
else if (band >= ActivityBand.Warm && important) else if (band >= ActivityBand.Warm)
{ {
// Important + interesting (not idle king/favorite). // Warm work is an action beat - not Story celebrity.
tier = InterestTier.Story; tier = InterestTier.Action;
} }
else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts)) else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts))
{ {
@ -299,15 +536,28 @@ public static class WorldActivityScanner
tier = InterestTier.Ambient; 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 return new InterestEvent
{ {
Tier = tier, Tier = tier,
Score = bestScore, Score = finalScore,
Position = best.current_position, Position = best.current_position,
FollowUnit = best, FollowUnit = best,
Label = FormatUnitLabel(best, speciesCounts), Label = FormatUnitLabel(best, speciesCounts),
CreatedAt = Time.unscaledTime, 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) 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) if (actor == null)
{ {
return 0f; return;
} }
float activity = ActivityInterestTable.ScoreActivity(actor); float activity = ActivityInterestTable.ScoreActivity(actor);
ActivityBand band = ActivityInterestTable.Classify(actor); ActivityBand band = ActivityInterestTable.Classify(actor);
actionScore = activity;
float importance = 0f; if (actor.has_attack_target)
if (actor.isFavorite())
{ {
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; actionScore += 8f;
}
else if (actor.is_profession_warrior)
{
importance += 6f;
} }
if (IsSpectacle(actor)) 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"; string speciesId = actor.asset != null ? actor.asset.id : "unknown";
@ -427,47 +714,20 @@ public static class WorldActivityScanner
&& speciesCounts.TryGetValue(speciesId, out int pop) && speciesCounts.TryGetValue(speciesId, out int pop)
&& pop == 1) && 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 actionScore *= 0.25f;
{ characterScore *= 0.35f;
int subCount = CountSubspeciesMembers(actor.subspecies);
if (subCount > 0 && subCount <= 3)
{
importance += 6f;
}
}
catch
{
// ignore
}
} }
importance += Mathf.Min(8f, actor.renown / 120f); actionScore += (actor.getID() % 7) * 0.01f;
// 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;
} }
/// <summary>True when the focused unit's current activity is Cold (idle scoring).</summary> /// <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 name = SafeName(actor);
string species = actor.asset != null ? actor.asset.id : "creature"; 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) if (actor.has_attack_target)
{ {
return $"Fighting: {name}"; return $"Fighting: {name}";
@ -567,6 +812,23 @@ public static class WorldActivityScanner
return $"Spectacle: {name} ({species})"; 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)) if (IsSingletonSpecies(actor, speciesCounts))
{ {
return $"Lone {species}: {name}"; return $"Lone {species}: {name}";
@ -779,7 +1041,10 @@ public static class WorldActivityScanner
FollowUnit = source.FollowUnit, FollowUnit = source.FollowUnit,
Label = source.Label, Label = source.Label,
CreatedAt = Time.unscaledTime, 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", "name": "IdleSpectator",
"author": "dazed", "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.", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }

View file

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