diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 303dfab..e0c8ef1 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2732,6 +2732,44 @@ public static class AgentHarness Emit(cmd, ok: true, detail: "variety_cleared"); break; + case "interest_variety_note": + { + // Stamp the current (or pending needle in value) as the last shown variety arc. + InterestCandidate c = InterestDirector.CurrentCandidate; + string needle = (cmd.value ?? "").Trim(); + if (!string.IsNullOrEmpty(needle)) + { + var pending = new System.Collections.Generic.List(64); + InterestRegistry.CopyPending(pending); + for (int i = 0; i < pending.Count; i++) + { + InterestCandidate p = pending[i]; + if (p?.Key != null + && p.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + c = p; + break; + } + } + } + + if (c == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no candidate to note"); + break; + } + + InterestVariety.NoteSelection(c, updateMix: false); + InterestScoring.RecalcTotal(c); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"arc={InterestVariety.LastArcKey} streak={InterestVariety.ArcStreak} key={c.Key}"); + break; + } + case "scoring_reload": { bool ok = InterestScoringConfig.Reload(); @@ -6996,8 +7034,35 @@ public static class AgentHarness } } + if (win != null) + { + InterestScoring.RecalcTotal(win); + } + + if (lose != null) + { + InterestScoring.RecalcTotal(lose); + } + pass = win != null && lose != null && win.TotalScore >= lose.TotalScore; - detail = $"win={win?.Key}({win?.TotalScore:0.#}) lose={lose?.Key}({lose?.TotalScore:0.#})"; + detail = $"win={win?.Key}({win?.TotalScore:0.#}) lose={lose?.Key}({lose?.TotalScore:0.#})" + + $" arc={InterestVariety.LastArcKey} streak={InterestVariety.ArcStreak}"; + break; + } + case "variety_arc": + { + string want = (cmd.value ?? "").Trim(); + string have = InterestVariety.LastArcKey ?? ""; + pass = !string.IsNullOrEmpty(want) + && have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0; + detail = $"arc='{have}' want='{want}' streak={InterestVariety.ArcStreak}"; + break; + } + case "variety_arc_streak": + { + int want = ParseInt(cmd.value, 1); + pass = InterestVariety.ArcStreak == want; + detail = $"streak={InterestVariety.ArcStreak} want={want} arc={InterestVariety.LastArcKey}"; break; } case "civic_boost": diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 35c380f..cebc549 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -1939,6 +1939,19 @@ internal static class HarnessScenarios Step("ap44", "assert", expect: "tip_contains", value: "RealFight"), Step("ap45", "assert", expect: "tip_not_contains", value: "KingStroll"), + // Consecutive same variety arc stacks a score penalty until a different arc shows. + Step("ap50", "interest_variety_clear"), + Step("ap50b", "interest_expire_pending", value: ""), + Step("ap51", "interest_inject", asset: "sheep", label: "FirstDuel", tier: "Action", expect: "first_duel", + value: "lead=event;evt=88;char=5;fighters=2;notables=0;force=true"), + Step("ap52", "assert", expect: "variety_arc", value: "combat:duel"), + Step("ap53", "assert", expect: "variety_arc_streak", value: "1"), + Step("ap54", "interest_inject", asset: "sheep", label: "RepeatDuel", tier: "Action", expect: "repeat_duel", + value: "lead=event;evt=100;char=5;fighters=2;notables=0"), + Step("ap55", "interest_inject", asset: "sheep", label: "FreshStory", tier: "Action", expect: "fresh_story", + value: "lead=event;evt=78;char=10"), + Step("ap56", "assert", expect: "interest_score_order", value: "fresh_story", label: "repeat_duel"), + // Cold-boot focus gaps from welcome/dismiss are unrelated to score order. Step("ap89", "reset_counters"), Step("ap90", "assert", expect: "no_bad"), diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index 91929ea..58a02b7 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -157,12 +157,15 @@ public static class InterestScoring } } + float repeatPenalty = InterestVariety.RepeatArcPenalty(c); c.TotalScore = c.EventStrength + scaleBonus + c.CharacterSignificance * charWeight + c.VisualConfidence * w.visualMultiplier - + c.Novelty * w.noveltyMultiplier; + + c.Novelty * w.noveltyMultiplier + - repeatPenalty; c.ScoreDetail = - $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}"; + $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}" + + (repeatPenalty > 0.05f ? $" rpt=-{repeatPenalty:0.#}" : ""); } /// diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 65b1547..d36c410 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -136,6 +136,13 @@ public class ScoringWeights /// on the same subject so life milestones do not reclaim the camera every few seconds. /// public float fixedDwellBeatCooldownSeconds = 45f; + /// + /// Score subtracted per consecutive camera show of the same variety arc + /// (e.g. duel → duel). Resets when a different arc is shown. + /// + public float repeatArcPenaltyPer = 24f; + /// Cap on stacked repeat-arc penalty. + public float repeatArcPenaltyCap = 72f; public int enrichTopK = 8; public float metaCacheTtl = 2f; } diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 6c632be..f442dde 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -5,8 +5,9 @@ using UnityEngine; namespace IdleSpectator; /// -/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties -/// and hard FixedDwell beat cooldowns (same life beat / subject cannot reclaim immediately). +/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties, +/// hard FixedDwell beat cooldowns, and consecutive variety-arc score drop-off +/// (same arc loses TotalScore until a different arc is shown). /// public static class InterestVariety { @@ -24,14 +25,25 @@ public static class InterestVariety private static readonly List CharPool = new List(64); private static readonly List Ranked = new List(16); + private static string _lastArcKey = ""; + private static int _arcStreak; + /// Whether the last saw both pools non-empty (for mix metering). public static bool LastPickHadBothPools { get; private set; } + /// Harness/debug: last shown variety arc key. + public static string LastArcKey => _lastArcKey ?? ""; + + /// Harness/debug: consecutive shows of . + public static int ArcStreak => _arcStreak; + public static void Clear() { RecentMix.Clear(); CooldownUntil.Clear(); LastPickHadBothPools = false; + _lastArcKey = ""; + _arcStreak = 0; } /// Harness: seed the rolling mix meter without touching cooldowns. @@ -68,6 +80,8 @@ public static class InterestVariety return; } + NoteVarietyArc(chosen); + if (updateMix) { RecentMix.Add(chosen.LeadKind); @@ -120,6 +134,161 @@ public static class InterestVariety } } + /// + /// Score to subtract when this candidate matches the last shown variety arc. + /// After one duel show, the next duel pays repeatArcPenaltyPer; a lover resets the streak. + /// + public static float RepeatArcPenalty(InterestCandidate c) + { + if (c == null || _arcStreak <= 0 || string.IsNullOrEmpty(_lastArcKey)) + { + return 0f; + } + + string arc = VarietyArcKey(c); + if (string.IsNullOrEmpty(arc) + || !string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase)) + { + return 0f; + } + + ScoringWeights w = InterestScoringConfig.W; + float per = w.repeatArcPenaltyPer > 0f ? w.repeatArcPenaltyPer : 24f; + float cap = w.repeatArcPenaltyCap > 0f ? w.repeatArcPenaltyCap : 72f; + return Mathf.Min(cap, _arcStreak * per); + } + + /// + /// Class-level arc for repeat drop-off (not per unit / pair id). + /// Duels share one arc so back-to-back scraps lose score; Mass/Battle share another. + /// + public static string VarietyArcKey(InterestCandidate c) + { + if (c == null) + { + return ""; + } + + if (c.Completion == InterestCompletionKind.WarFront + || string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase)) + { + return "arc:war"; + } + + if (c.Completion == InterestCompletionKind.PlotActive + || string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase)) + { + return "arc:plot"; + } + + if (c.Completion == InterestCompletionKind.StatusOutbreak + || string.Equals(c.AssetId, "live_outbreak", StringComparison.OrdinalIgnoreCase)) + { + return "arc:outbreak"; + } + + if (c.Completion == InterestCompletionKind.CombatActive + || string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.Verb, "fighting", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)) + { + return "arc:combat:multi"; + } + + ScoringWeights w = InterestScoringConfig.W; + int sideSum = Mathf.Max(0, c.CombatSideACount) + Mathf.Max(0, c.CombatSideBCount); + int size = Mathf.Max(Mathf.Max(0, c.ParticipantCount), sideSum); + int duelMax = Mathf.Max(1, w.duelMaxFighters); + if (size > 0 && size <= duelMax) + { + return "arc:combat:duel"; + } + + return "arc:combat:multi"; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId)) + { + string h = c.HappinessEffectId.Trim().ToLowerInvariant(); + if (h.IndexOf("death_", StringComparison.Ordinal) >= 0 + || h.IndexOf("mourn", StringComparison.Ordinal) >= 0 + || h.IndexOf("despair", StringComparison.Ordinal) >= 0 + || h.IndexOf("grief", StringComparison.Ordinal) >= 0) + { + return "arc:life:grief"; + } + + if (h.IndexOf("lover", StringComparison.Ordinal) >= 0 + || h.IndexOf("love", StringComparison.Ordinal) >= 0 + || h.IndexOf("married", StringComparison.Ordinal) >= 0 + || h.IndexOf("wedding", StringComparison.Ordinal) >= 0) + { + return "arc:life:love"; + } + + return "arc:hap:" + h; + } + + if (!string.IsNullOrEmpty(c.StatusId)) + { + return "arc:status:" + c.StatusId.Trim().ToLowerInvariant(); + } + + string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.Ordinal)) + { + if (string.Equals(c.Category, "Spectacle", StringComparison.OrdinalIgnoreCase) + || asset.StartsWith("cast_", StringComparison.Ordinal) + || asset.StartsWith("summon_", StringComparison.Ordinal)) + { + return "arc:spectacle"; + } + + if (string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase) + || (c.Label != null + && c.Label.IndexOf("decides to", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return "arc:decision"; + } + + return "arc:asset:" + asset; + } + + if (!string.IsNullOrEmpty(c.Category)) + { + return "arc:cat:" + c.Category.Trim().ToLowerInvariant(); + } + + if (!string.IsNullOrEmpty(c.Verb)) + { + return "arc:verb:" + c.Verb.Trim().ToLowerInvariant(); + } + + return ""; + } + + private static void NoteVarietyArc(InterestCandidate chosen) + { + string arc = VarietyArcKey(chosen); + if (string.IsNullOrEmpty(arc)) + { + return; + } + + if (string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase)) + { + _arcStreak = Mathf.Max(1, _arcStreak + 1); + } + else + { + _lastArcKey = arc; + _arcStreak = 1; + } + } + /// /// True when a FixedDwell / moment beat for this candidate is still hard-cooled. /// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked. diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index c313ed5..824c8f3 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.33", + "version": "0.28.34", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index 844f601..fcdf78f 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -1,5 +1,5 @@ { - "modVersion": "0.28.32", + "modVersion": "0.28.34", "title": "IdleSpectator interest scoring model", "updated": "2026-07-17", "note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).", @@ -98,6 +98,8 @@ "mixWindow": 20, "hardCooldownSeconds": 12, "fixedDwellBeatCooldownSeconds": 45, + "repeatArcPenaltyPer": 24, + "repeatArcPenaltyCap": 72, "enrichTopK": 8, "metaCacheTtl": 2 }, @@ -119,6 +121,7 @@ "Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.", "Extremely notable 1v1s (kings/favorites) can beat nameless melees.", "Founding tips fire on meta outcomes (new_city / new_kingdom), not decision intent.", + "Consecutive same variety-arc shows stack a score penalty until a different arc is shown.", "TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts." ], "pipeline": [ diff --git a/docs/scoring-model.md b/docs/scoring-model.md index 92df30d..d4023c0 100644 --- a/docs/scoring-model.md +++ b/docs/scoring-model.md @@ -23,6 +23,8 @@ Anonymous / single-notable Duels are soft sticky: MaxWatch 24, variety valve at Founding tips come from `new_city` / `new_kingdom` meta outcomes, not decision intent. +Consecutive shows of the same variety arc (e.g. duel → duel) subtract `repeatArcPenaltyPer` from TotalScore (capped), resetting when a different arc is shown. + Edit the `weights` block in scoring-model.json to change live scoring formula knobs. Edit event-catalog.json to change what each event is worth and what it says.