Introduce variety arc scoring penalties and enhance interest handling.

- Implement scoring penalties for consecutive same variety arc shows
- Update InterestVariety and InterestScoring to track arc streaks
- Add new commands for noting interest variety in AgentHarness
- Expand HarnessScenarios to test new variety arc mechanics
- Increment version to 0.28.34 in mod.json and scoring-model.json
This commit is contained in:
DazedAnon 2026-07-17 19:07:47 -05:00
parent 7d23f590db
commit 394968a956
8 changed files with 269 additions and 7 deletions

View file

@ -2732,6 +2732,44 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: "variety_cleared"); Emit(cmd, ok: true, detail: "variety_cleared");
break; 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<InterestCandidate>(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": case "scoring_reload":
{ {
bool ok = InterestScoringConfig.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; 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; break;
} }
case "civic_boost": case "civic_boost":

View file

@ -1939,6 +1939,19 @@ internal static class HarnessScenarios
Step("ap44", "assert", expect: "tip_contains", value: "RealFight"), Step("ap44", "assert", expect: "tip_contains", value: "RealFight"),
Step("ap45", "assert", expect: "tip_not_contains", value: "KingStroll"), 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. // Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
Step("ap89", "reset_counters"), Step("ap89", "reset_counters"),
Step("ap90", "assert", expect: "no_bad"), Step("ap90", "assert", expect: "no_bad"),

View file

@ -157,12 +157,15 @@ public static class InterestScoring
} }
} }
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
c.TotalScore = c.EventStrength + scaleBonus c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight + c.CharacterSignificance * charWeight
+ c.VisualConfidence * w.visualMultiplier + c.VisualConfidence * w.visualMultiplier
+ c.Novelty * w.noveltyMultiplier; + c.Novelty * w.noveltyMultiplier
- repeatPenalty;
c.ScoreDetail = 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.#}" : "");
} }
/// <summary> /// <summary>

View file

@ -136,6 +136,13 @@ public class ScoringWeights
/// on the same subject so life milestones do not reclaim the camera every few seconds. /// on the same subject so life milestones do not reclaim the camera every few seconds.
/// </summary> /// </summary>
public float fixedDwellBeatCooldownSeconds = 45f; public float fixedDwellBeatCooldownSeconds = 45f;
/// <summary>
/// Score subtracted per consecutive camera show of the same variety arc
/// (e.g. duel → duel). Resets when a different arc is shown.
/// </summary>
public float repeatArcPenaltyPer = 24f;
/// <summary>Cap on stacked repeat-arc penalty.</summary>
public float repeatArcPenaltyCap = 72f;
public int enrichTopK = 8; public int enrichTopK = 8;
public float metaCacheTtl = 2f; public float metaCacheTtl = 2f;
} }

View file

@ -5,8 +5,9 @@ using UnityEngine;
namespace IdleSpectator; namespace IdleSpectator;
/// <summary> /// <summary>
/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties /// 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). /// hard FixedDwell beat cooldowns, and consecutive variety-arc score drop-off
/// (same arc loses TotalScore until a different arc is shown).
/// </summary> /// </summary>
public static class InterestVariety public static class InterestVariety
{ {
@ -24,14 +25,25 @@ public static class InterestVariety
private static readonly List<InterestCandidate> CharPool = new List<InterestCandidate>(64); private static readonly List<InterestCandidate> CharPool = new List<InterestCandidate>(64);
private static readonly List<InterestCandidate> Ranked = new List<InterestCandidate>(16); private static readonly List<InterestCandidate> Ranked = new List<InterestCandidate>(16);
private static string _lastArcKey = "";
private static int _arcStreak;
/// <summary>Whether the last <see cref="Pick"/> saw both pools non-empty (for mix metering).</summary> /// <summary>Whether the last <see cref="Pick"/> saw both pools non-empty (for mix metering).</summary>
public static bool LastPickHadBothPools { get; private set; } public static bool LastPickHadBothPools { get; private set; }
/// <summary>Harness/debug: last shown variety arc key.</summary>
public static string LastArcKey => _lastArcKey ?? "";
/// <summary>Harness/debug: consecutive shows of <see cref="LastArcKey"/>.</summary>
public static int ArcStreak => _arcStreak;
public static void Clear() public static void Clear()
{ {
RecentMix.Clear(); RecentMix.Clear();
CooldownUntil.Clear(); CooldownUntil.Clear();
LastPickHadBothPools = false; LastPickHadBothPools = false;
_lastArcKey = "";
_arcStreak = 0;
} }
/// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary> /// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary>
@ -68,6 +80,8 @@ public static class InterestVariety
return; return;
} }
NoteVarietyArc(chosen);
if (updateMix) if (updateMix)
{ {
RecentMix.Add(chosen.LeadKind); RecentMix.Add(chosen.LeadKind);
@ -120,6 +134,161 @@ public static class InterestVariety
} }
} }
/// <summary>
/// Score to subtract when this candidate matches the last shown variety arc.
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary> /// <summary>
/// True when a FixedDwell / moment beat for this candidate is still hard-cooled. /// True when a FixedDwell / moment beat for this candidate is still hard-cooled.
/// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked. /// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked.

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.28.33", "version": "0.28.34",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }

View file

@ -1,5 +1,5 @@
{ {
"modVersion": "0.28.32", "modVersion": "0.28.34",
"title": "IdleSpectator interest scoring model", "title": "IdleSpectator interest scoring model",
"updated": "2026-07-17", "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).", "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, "mixWindow": 20,
"hardCooldownSeconds": 12, "hardCooldownSeconds": 12,
"fixedDwellBeatCooldownSeconds": 45, "fixedDwellBeatCooldownSeconds": 45,
"repeatArcPenaltyPer": 24,
"repeatArcPenaltyCap": 72,
"enrichTopK": 8, "enrichTopK": 8,
"metaCacheTtl": 2 "metaCacheTtl": 2
}, },
@ -119,6 +121,7 @@
"Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.", "Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.",
"Extremely notable 1v1s (kings/favorites) can beat nameless melees.", "Extremely notable 1v1s (kings/favorites) can beat nameless melees.",
"Founding tips fire on meta outcomes (new_city / new_kingdom), not decision intent.", "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." "TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts."
], ],
"pipeline": [ "pipeline": [

View file

@ -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. 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 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. Edit event-catalog.json to change what each event is worth and what it says.