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