reworking scoring
This commit is contained in:
parent
d260472cc4
commit
77cb688925
11 changed files with 982 additions and 165 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
|
|||
director_lifecycle
|
||||
interest_happiness
|
||||
director_gaps
|
||||
director_action_priority
|
||||
discovery
|
||||
world_log
|
||||
chronicle_smoke
|
||||
|
|
|
|||
Loading…
Reference in a new issue