Compare commits

...

2 commits

Author SHA1 Message Date
5cdea1930a Enhance combat handling and introduce frequency-based scoring adjustments.
- Implement new commands for marking combat cold and pushing interest arcs
- Update InterestDirector to manage forced cold combat states for peers
- Introduce frequency adjustments for scoring based on recent arc representation
- Refactor scoring weights to include combat urgency and frequency penalties
- Increment version to 0.28.38 in mod.json and scoring-model.json
2026-07-17 19:27:07 -05:00
394968a956 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
2026-07-17 19:07:47 -05:00
11 changed files with 720 additions and 124 deletions

View file

@ -2732,6 +2732,70 @@ 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<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 "interest_variety_push_arc":
{
// value = arc key (or short "combat:duel"), label/count = times
string arc = (cmd.value ?? "").Trim();
if (!string.IsNullOrEmpty(arc) && !arc.StartsWith("arc:", StringComparison.OrdinalIgnoreCase))
{
arc = "arc:" + arc;
}
int times = cmd.count > 0 ? cmd.count : ParseInt(cmd.label, 8);
if (string.IsNullOrEmpty(arc))
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no arc");
break;
}
InterestVariety.HarnessPushArc(arc, times);
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"pushed={arc} x{times} window={InterestVariety.ArcWindowCount}");
break;
}
case "scoring_reload":
{
bool ok = InterestScoringConfig.Reload();
@ -2817,6 +2881,27 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}");
break;
case "interest_mark_combat_cold":
{
// Drop attack/combat signals so live path cannot refresh hot; force-cold gates the hold.
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene != null)
{
ClearAttackTarget(scene.FollowUnit);
ClearAttackTarget(scene.RelatedUnit);
TryClearCombatTask(scene.FollowUnit);
TryClearCombatTask(scene.RelatedUnit);
}
InterestDirector.HarnessMarkCombatCold();
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"combat_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
break;
}
case "interest_mark_inactive":
{
float ago = ParseFloat(cmd.value, 1f);
@ -6996,8 +7081,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":

View file

@ -1,3 +1,4 @@
using System;
using ai.behaviours;
using HarmonyLib;
using UnityEngine;
@ -37,7 +38,13 @@ public static class BuildingEventPatches
}
string label = EventReason.BuildingEat(pActor, food);
Emit("building_consume", 78f, pActor, label);
bool fruitForage = IsFruitForage(food);
// Fruit bushes are high-rate ambient - fill-band strength so they do not crowd combat/story.
// Real settlement eats (granary/etc.) stay camera-competitive.
float strength = fruitForage ? 42f : 78f;
string category = fruitForage ? "Forage" : "Spectacle";
string assetId = fruitForage ? "building_consume_fruit" : "building_consume";
Emit(assetId, strength, category, pActor, label);
}
[HarmonyPatch(typeof(Building), "startDestroyBuilding")]
@ -102,7 +109,25 @@ public static class BuildingEventPatches
maxWatch: spectacle ? 28f : 18f);
}
private static void Emit(string eventId, float strength, Actor actor, string label)
private static bool IsFruitForage(Building food)
{
if (food?.asset == null)
{
return false;
}
try
{
string type = food.asset.type ?? "";
return string.Equals(type, "type_fruits", StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private static void Emit(string eventId, float strength, string category, Actor actor, string label)
{
if (!AgentHarness.LiveFeedsAllowed)
{
@ -113,7 +138,7 @@ public static class BuildingEventPatches
EventFeedUtil.Register(
key,
"building",
"Spectacle",
string.IsNullOrEmpty(category) ? "Spectacle" : category,
label,
eventId,
strength,

View file

@ -59,9 +59,11 @@ internal static class HarnessScenarios
case "sticky_variety_valve":
case "variety_valve":
return StickyVarietyValve();
case "combat_hold_until_end":
case "combat_hold":
case "soft_duel_yield":
case "duel_yield":
return SoftDuelYield();
return CombatHoldUntilEnd();
case "war_front_sticky":
case "war_sticky":
return WarFrontSticky();
@ -218,7 +220,7 @@ internal static class HarnessScenarios
Nested("reg_director_gaps", "director_gaps"),
Nested("reg_action_priority", "director_action_priority"),
Nested("reg_variety_valve", "sticky_variety_valve"),
Nested("reg_soft_duel_yield", "soft_duel_yield"),
Nested("reg_combat_hold", "combat_hold_until_end"),
Nested("reg_combat_stability", "combat_stability_live"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
@ -1316,7 +1318,7 @@ internal static class HarnessScenarios
}
/// <summary>
/// After sticky combat matures, a Story/lover peer may cut in via the variety valve.
/// Live Mass combat holds through story peers; after the scrap goes cold, variety valve may cut.
/// </summary>
private static List<HarnessCommand> StickyVarietyValve()
{
@ -1344,9 +1346,19 @@ internal static class HarnessScenarios
expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("sv24b", "assert", expect: "pending_contains", value: "valve_lover"),
Step("sv25", "director_run", wait: 1.2f),
Step("sv26", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
Step("sv27", "assert", expect: "tip_not_contains", value: "Mass -"),
Step("sv28", "assert", expect: "tip_asset", value: "set_lover"),
// Live scrap: hold through the peer (see who wins).
Step("sv26", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
Step("sv27", "assert", expect: "tip_not_contains", value: "falls in love"),
// Fight over → story peer may cut. Clear discovery noise so the lover is the peer under test.
Step("sv28", "interest_expire_pending", value: ""),
Step("sv29", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("sv30", "interest_mark_combat_cold"),
Step("sv31", "director_run", wait: 1.2f),
Step("sv32", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
Step("sv33", "assert", expect: "tip_not_contains", value: "Mass -"),
Step("sv34", "assert", expect: "tip_asset", value: "set_lover"),
Step("sv90", "fast_timing", value: "false"),
Step("sv99", "snapshot"),
@ -1354,49 +1366,75 @@ internal static class HarnessScenarios
}
/// <summary>
/// Anonymous pair Duels are soft sticky: after a short hold, a Story/lover peer may cut in
/// without needing Mass-tier sticky margin.
/// Live Duels hold through ordinary peers; catalog epic-world urgency may cut mid-fight;
/// after the scrap goes cold, Story peers may cut.
/// </summary>
private static List<HarnessCommand> SoftDuelYield()
private static List<HarnessCommand> CombatHoldUntilEnd()
{
return new List<HarnessCommand>
{
Step("sd0", "dismiss_windows"),
Step("sd1", "wait_world"),
Step("sd2", "set_setting", expect: "enabled", value: "true"),
Step("sd3", "fast_timing", value: "true"),
Step("sd4", "spawn", asset: "human", count: 1),
Step("sd5", "spawn", asset: "wolf", count: 1),
Step("sd6", "spectator", value: "off"),
Step("sd7", "spectator", value: "on"),
Step("sd8", "pick_unit", asset: "human"),
Step("sd9", "focus", asset: "human"),
Step("sd10", "interest_end_session"),
Step("ch0", "dismiss_windows"),
Step("ch1", "wait_world"),
Step("ch2", "set_setting", expect: "enabled", value: "true"),
Step("ch3", "fast_timing", value: "true"),
Step("ch4", "spawn", asset: "human", count: 1),
Step("ch5", "spawn", asset: "wolf", count: 1),
Step("ch6", "spectator", value: "off"),
Step("ch7", "spectator", value: "on"),
Step("ch8", "pick_unit", asset: "human"),
Step("ch9", "focus", asset: "human"),
Step("ch10", "interest_end_session"),
// Do not combat_wire_attack_sides - that absorbs leftover same-species fighters into Battle.
// "no_attack" in expect skips attack wiring (keeps pair alive); tip still uses Duel framing.
Step("sd20", "interest_combat_session", asset: "human", value: "wolf",
expect: "sd_pack no_attack"),
Step("sd21b", "status_apply", asset: "human", value: "invincible"),
Step("sd21c", "status_apply", asset: "wolf", value: "invincible"),
Step("sd22", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("sd23", "assert", expect: "tip_not_contains", value: "Mass -"),
Step("sd24", "assert", expect: "tip_not_contains", value: "Battle -"),
Step("sd25", "assert", expect: "tip_not_contains", value: "Skirmish -"),
// Wire attack so CombatStillActive stays hot; invincible so the pair survives the assert.
// Isolate so leftover world packs cannot escalate a 1v1 into Battle mid-hold.
Step("ch20", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack"),
Step("ch20b", "combat_isolate_pair", value: "20"),
Step("ch21b", "status_apply", asset: "human", value: "invincible"),
Step("ch21c", "status_apply", asset: "wolf", value: "invincible"),
Step("ch22", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("ch23", "assert", expect: "tip_not_contains", value: "Mass -"),
Step("sd29", "interest_expire_pending", value: ""),
Step("sd30", "age_current", wait: 9f),
Step("sd30b", "combat_maintain_focus"),
Step("sd31", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
expect: "sd_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("sd32", "assert", expect: "pending_contains", value: "sd_lover"),
Step("sd33", "director_run", wait: 1.2f),
Step("sd34", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
Step("sd35", "assert", expect: "tip_not_contains", value: "Duel -"),
Step("sd36", "assert", expect: "tip_asset", value: "set_lover"),
Step("ch29", "interest_expire_pending", value: ""),
Step("ch30", "age_current", wait: 9f),
Step("ch30b", "combat_maintain_focus"),
Step("ch30c", "assert", expect: "tip_matches_any", value: "Duel -"),
// Ordinary Story/Action peer: hold the scrap.
Step("ch31", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
expect: "ch_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("ch32", "assert", expect: "pending_contains", value: "ch_lover"),
Step("ch33", "director_run", wait: 1.2f),
Step("ch34", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("ch35", "assert", expect: "tip_not_contains", value: "falls in love"),
Step("sd90", "fast_timing", value: "false"),
Step("sd99", "snapshot"),
// Extremely urgent (catalog epic-world EventStrength) may cut mid-fight.
Step("ch36", "interest_expire_pending", value: "ch_lover"),
Step("ch37", "interest_inject", asset: "human", label: "UrgentCut", tier: "Epic",
expect: "ch_urgent", value: "lead=event;evt=150;char=10;force=false;ttl=60"),
Step("ch38", "assert", expect: "pending_contains", value: "ch_urgent"),
Step("ch39", "director_run", wait: 1.2f),
Step("ch39b", "assert", expect: "tip_contains", value: "UrgentCut"),
Step("ch39c", "assert", expect: "tip_not_contains", value: "Duel -"),
// Fresh scrap: after cold, ordinary peers may cut again.
Step("ch40", "interest_end_session"),
Step("ch41", "interest_expire_pending", value: ""),
Step("ch42", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack2"),
Step("ch42b", "combat_isolate_pair", value: "20"),
Step("ch42c", "status_apply", asset: "human", value: "invincible"),
Step("ch42d", "status_apply", asset: "wolf", value: "invincible"),
Step("ch43", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("ch44", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
expect: "ch_lover2", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("ch45", "director_run", wait: 1.2f),
Step("ch46", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("ch47", "interest_mark_combat_cold"),
Step("ch48", "director_run", wait: 1.2f),
Step("ch49", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
Step("ch50", "assert", expect: "tip_not_contains", value: "Duel -"),
Step("ch51", "assert", expect: "tip_asset", value: "set_lover"),
Step("ch90", "fast_timing", value: "false"),
Step("ch99", "snapshot"),
};
}
@ -1939,6 +1977,29 @@ 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"),
// Rolling rarity: flood the window with duels → a rare spectacle outranks another duel.
Step("ap60", "interest_variety_clear"),
Step("ap60b", "interest_expire_pending", value: ""),
Step("ap61", "interest_variety_push_arc", value: "combat:duel", count: 12),
Step("ap62", "interest_inject", asset: "sheep", label: "CommonDuel", tier: "Action", expect: "common_duel",
value: "lead=event;evt=100;char=5;fighters=2;notables=0"),
Step("ap63", "interest_inject", asset: "sheep", label: "RareSpell", tier: "Action", expect: "rare_spell",
value: "lead=event;evt=86;char=10"),
Step("ap64", "assert", expect: "interest_score_order", value: "rare_spell", label: "common_duel"),
// Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
Step("ap89", "reset_counters"),
Step("ap90", "assert", expect: "no_bad"),

View file

@ -89,6 +89,11 @@ public static class InterestCompletion
private static bool CombatStillActive(InterestCandidate c)
{
if (InterestDirector.HarnessCombatForcedCold)
{
return false;
}
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{

View file

@ -47,6 +47,11 @@ public static class InterestDirector
private static float _lastCombatFocusAt = -999f;
/// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary>
private static bool _varietyValveUsed;
/// <summary>
/// Harness-only: treat the current CombatActive scene as cold so peers can cut after a fight
/// without waiting for WorldBox to drop attack_target / combat tasks.
/// </summary>
private static bool _harnessCombatForcedCold;
private const float CombatFocusThrottleSeconds = 1.25f;
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
private const float CombatFocusThrottleLargeSeconds = 2.0f;
@ -268,6 +273,33 @@ public static class InterestDirector
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds);
}
/// <summary>
/// Harness: force the current combat scene cold (fight over) without ending the session,
/// so quiet-grace / variety-valve peers may cut after a live hold.
/// </summary>
/// <summary>Harness: current CombatActive is forced cold until the next scene switch.</summary>
public static bool HarnessCombatForcedCold => _harnessCombatForcedCold;
public static void HarnessMarkCombatCold()
{
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
return;
}
float now = Time.unscaledTime;
_harnessCombatForcedCold = true;
_current.ForceActive = false;
_current.LastSeenAt = now - 60f;
// Skip HasLiveCombatNear sticky path while proving post-fight cuts.
if (string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
{
_current.AssetId = "live_combat";
}
_inactiveSince = now;
}
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
public static bool HarnessClearFollowForHandoff(Actor related)
{
@ -439,8 +471,9 @@ public static class InterestDirector
return;
}
// Max cap ends the scene even if still "active".
if (onCurrent >= MaxWatchFor(_current))
// Max cap ends the scene even if still "active" - except live combat, which
// holds until the scrap goes cold so the viewer can see who wins.
if (onCurrent >= MaxWatchFor(_current) && !CombatFightIsHot(_current, now))
{
EndCurrent(now, reason: "max_cap");
return;
@ -3217,6 +3250,7 @@ public static class InterestDirector
_interrupted = null;
_current = null;
_harnessCombatForcedCold = false;
_inactiveSince = -999f;
EnsureIdleFocus(now);
}
@ -3542,9 +3576,7 @@ public static class InterestDirector
switch (current.Completion)
{
case InterestCompletionKind.CombatActive:
classCap = IsSoftStickyCombat(current)
? (w.maxWatchDuel > 0f ? w.maxWatchDuel : 24f)
: w.maxWatchCombat;
classCap = w.maxWatchCombat;
break;
case InterestCompletionKind.WarFront:
case InterestCompletionKind.PlotActive:
@ -3580,15 +3612,9 @@ public static class InterestDirector
float cap = classCap;
if (current.MaxWatch > 0f)
{
// Soft pair Duels: honor maxWatchDuel - scanner/harness often stamp MaxWatch=60.
if (current.Completion == InterestCompletionKind.CombatActive
&& IsSoftStickyCombat(current))
{
cap = classCap;
}
// Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a
// short candidate MaxWatch max_cap the fight while it is still active.
else if (current.Completion == InterestCompletionKind.CombatActive
if (current.Completion == InterestCompletionKind.CombatActive
|| current.Completion == InterestCompletionKind.WarFront
|| current.Completion == InterestCompletionKind.PlotActive
|| current.Completion == InterestCompletionKind.StatusPhase
@ -3701,6 +3727,27 @@ public static class InterestDirector
}
float now = Time.unscaledTime;
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
// Same-arc theater refresh / absorb still allowed.
if (CombatFightIsHot(_current, now)
&& !IsSameStoryArc(_current, candidate)
&& candidate.Completion != InterestCompletionKind.CombatActive)
{
if (IsCombatUrgentPeer(_current, candidate))
{
InterestDropLog.Record(
"combat_urgent_cut",
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
}
InterestDropLog.Record(
"combat_hold",
$"cur={_current.Key} next={candidate.Key}");
return false;
}
bool inGrace = InQuietGrace;
if (inGrace)
{
@ -3771,33 +3818,17 @@ public static class InterestDirector
return true;
}
// Soft family pack / thin Duel: yield to discrete unit events after a short hold.
// Soft family pack: yield to discrete unit events after a short hold.
if (IsSoftStickyCluster(_current)
&& !IsSoftStickyCluster(candidate)
&& !nextFill
&& !IsAmbientShot(candidate)
&& onCurrent >= SoftYieldSecondsFor(_current)
&& onCurrent >= SoftClusterYieldSeconds
&& nextScore >= curScore - w.rotateSlack)
{
return true;
}
// Soft Duel class gate: notice-tier Story peers cut after SoftCombatYieldSeconds.
// Harness combat sessions seed EventStrength ~120 so score-slack yield alone never clears;
// live anon Duels with VisualConfidence can sit in the same band.
if (IsSoftStickyCombat(_current)
&& IsVarietyValveCandidate(candidate)
&& !nextFill
&& !IsAmbientShot(candidate)
&& onCurrent >= SoftCombatYieldSeconds
&& (nextScore >= w.noticeScoreMin || candidate.EventStrength >= w.noticeScoreMin))
{
InterestDropLog.Record(
"soft_duel_yield",
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
return true;
}
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
@ -3869,63 +3900,66 @@ public static class InterestDirector
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
private const float SoftClusterYieldSeconds = 4f;
/// <summary>Thin Duel soft-yield - longer than pack so scraps still read, shorter than hard sticky.</summary>
private const float SoftCombatYieldSeconds = 8f;
private static bool IsSoftStickyCluster(InterestCandidate c) =>
c != null
&& (c.Completion == InterestCompletionKind.FamilyPack || IsSoftStickyCombat(c));
c != null && c.Completion == InterestCompletionKind.FamilyPack;
/// <summary>True while a CombatActive scene still has a live scrap (incl. hysteresis).</summary>
private static bool CombatFightIsHot(InterestCandidate scene, float now)
{
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
if (_harnessCombatForcedCold && scene == _current)
{
return false;
}
return InterestCompletion.IsActive(scene, _currentStartedAt, now);
}
/// <summary>
/// Pair scrap without two notables - soft sticky (normal cut-in, short MaxWatch, early valve).
/// Hard sticky reserved for Skirmish+ (<c>live_battle</c>), larger rosters, or notable-pair Duels.
/// Do not use ContestedFighters here: lopsided Mass tips can stamp sides as 1v1 while the roster is large.
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
/// </summary>
private static bool IsSoftStickyCombat(InterestCandidate c)
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
{
if (c == null || c.Completion != InterestCompletionKind.CombatActive)
if (current == null || next == null)
{
return false;
}
// Ensemble framing (Skirmish/Battle/Mass) always hard-holds.
if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (c.NotableParticipantCount >= 2)
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
{
return false;
}
ScoringWeights w = InterestScoringConfig.W;
int sideSum = Math.Max(0, c.CombatSideACount) + Math.Max(0, c.CombatSideBCount);
int size = Math.Max(Math.Max(0, c.ParticipantCount), sideSum);
int skirmishFloor = Math.Max(3, w.skirmishMinFighters);
if (size >= skirmishFloor)
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
if (next.TotalScore >= current.TotalScore + stickyMargin)
{
return false;
return true;
}
int duelMax = Math.Max(1, w.duelMaxFighters);
// size 0 during attack_target gaps: keep soft if we never escalated past a pair.
if (size == 0)
{
return c.CombatPeakParticipants <= duelMax;
}
return size <= duelMax;
float epicMin = w.combatUrgentEventStrengthMin > 0f
? w.combatUrgentEventStrengthMin
: 95f;
float urgentMargin = w.combatUrgentCutInMargin > 0f
? w.combatUrgentCutInMargin
: 20f;
return next.EventStrength >= epicMin
&& next.TotalScore >= current.TotalScore + urgentMargin;
}
private static float SoftYieldSecondsFor(InterestCandidate c) =>
IsSoftStickyCombat(c) ? SoftCombatYieldSeconds : SoftClusterYieldSeconds;
/// <summary>
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
/// </summary>
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
!_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent);
!_varietyValveUsed
&& VarietyValveTimeReady(scene, onCurrent)
&& !CombatFightIsHot(scene, Time.unscaledTime);
private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent)
{
@ -3937,11 +3971,7 @@ public static class InterestDirector
}
ScoringWeights w = InterestScoringConfig.W;
float after = IsSoftStickyCombat(scene)
? (w.stickyVarietyValveAfterSecondsPair > 0f
? w.stickyVarietyValveAfterSecondsPair
: 8f)
: (w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f);
float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f;
if (onCurrent >= after)
{
return true;
@ -4472,6 +4502,7 @@ public static class InterestDirector
_currentStartedAt = now;
_lastSwitchAt = now;
_inactiveSince = -999f;
_harnessCombatForcedCold = false;
if (!InterestScoring.IsFillScore(next.TotalScore))
{
_lastInterestingAt = now;

View file

@ -157,12 +157,29 @@ public static class InterestScoring
}
}
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
+ c.VisualConfidence * w.visualMultiplier
+ c.Novelty * w.noveltyMultiplier;
c.ScoreDetail =
+ c.Novelty * w.noveltyMultiplier
- repeatPenalty
+ frequencyAdjust;
string detail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
if (repeatPenalty > 0.05f)
{
detail += $" rpt=-{repeatPenalty:0.#}";
}
if (Mathf.Abs(frequencyAdjust) > 0.05f)
{
detail += " freq="
+ (frequencyAdjust >= 0f ? "+" : "")
+ frequencyAdjust.ToString("0.#");
}
c.ScoreDetail = detail;
}
/// <summary>

View file

@ -30,6 +30,16 @@ public class ScoringWeights
public float cutInMargin = 35f;
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
public float stickyCutInMargin = 50f;
/// <summary>
/// EventStrength floor for mid-fight urgent cuts (catalog epic-world band, 95-100).
/// Disasters / kingdom fall may interrupt a live scrap; lovers and daily Action may not.
/// </summary>
public float combatUrgentEventStrengthMin = 95f;
/// <summary>
/// Score margin over the live fight required when EventStrength clears
/// <see cref="combatUrgentEventStrengthMin"/> (smaller than stickyCutInMargin).
/// </summary>
public float combatUrgentCutInMargin = 20f;
/// <summary>After this many seconds on sticky combat/war, variety-valve peers may cut in.</summary>
public float stickyVarietyValveAfterSeconds = 16f;
/// <summary>
@ -136,6 +146,25 @@ public class ScoringWeights
/// on the same subject so life milestones do not reclaim the camera every few seconds.
/// </summary>
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;
/// <summary>Rolling tip window for rarity/commonality score adjust.</summary>
public int arcFrequencyWindow = 16;
/// <summary>Soft per-arc share target inside the window (common arcs above this lose score).</summary>
public float arcFairShare = 0.12f;
/// <summary>Max TotalScore penalty when an arc saturates the window.</summary>
public float arcOverSharePenaltyMax = 48f;
/// <summary>Max TotalScore boost when an arc is absent from the window (rare).</summary>
public float arcUnderShareBoostMax = 22f;
/// <summary>Soft share cap for all combat arcs combined (duel + multi).</summary>
public float arcCombatShareTarget = 0.35f;
/// <summary>Extra penalty when combat share exceeds <see cref="arcCombatShareTarget"/>.</summary>
public float arcCombatOverSharePenaltyMax = 36f;
public int enrichTopK = 8;
public float metaCacheTtl = 2f;
}

View file

@ -5,8 +5,9 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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, consecutive arc drop-off, and a rolling
/// rarity window (common arcs lose score; rare/absent arcs gain score).
/// </summary>
public static class InterestVariety
{
@ -17,6 +18,7 @@ public static class InterestVariety
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
private static readonly List<string> RecentArcs = new List<string>(32);
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
private static readonly System.Random Rng = new System.Random();
@ -24,14 +26,44 @@ public static class InterestVariety
private static readonly List<InterestCandidate> CharPool = new List<InterestCandidate>(64);
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>
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;
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
public static int ArcWindowCount => RecentArcs.Count;
public static void Clear()
{
RecentMix.Clear();
RecentArcs.Clear();
CooldownUntil.Clear();
LastPickHadBothPools = false;
_lastArcKey = "";
_arcStreak = 0;
}
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary>
public static void HarnessPushArc(string arcKey, int times = 1)
{
if (string.IsNullOrEmpty(arcKey))
{
return;
}
int n = Mathf.Max(1, times);
for (int i = 0; i < n; i++)
{
PushArc(arcKey);
}
}
/// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary>
@ -68,6 +100,8 @@ public static class InterestVariety
return;
}
NoteVarietyArc(chosen);
if (updateMix)
{
RecentMix.Add(chosen.LeadKind);
@ -120,6 +154,267 @@ 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>
/// Rolling rarity: arcs over-represented in the recent tip window lose score;
/// absent/rare arcs gain score. Combat as a family gets an extra monopoly penalty.
/// </summary>
public static float FrequencyAdjust(InterestCandidate c)
{
if (c == null || RecentArcs.Count == 0)
{
return 0f;
}
string arc = VarietyArcKey(c);
if (string.IsNullOrEmpty(arc))
{
return 0f;
}
ScoringWeights w = InterestScoringConfig.W;
int window = Mathf.Max(4, w.arcFrequencyWindow > 0 ? w.arcFrequencyWindow : 16);
int sample = Mathf.Min(RecentArcs.Count, window);
if (sample <= 0)
{
return 0f;
}
int arcCount = 0;
int combatCount = 0;
int start = RecentArcs.Count - sample;
for (int i = start; i < RecentArcs.Count; i++)
{
string a = RecentArcs[i];
if (string.Equals(a, arc, StringComparison.OrdinalIgnoreCase))
{
arcCount++;
}
if (IsCombatArc(a))
{
combatCount++;
}
}
float share = arcCount / (float)sample;
float fair = w.arcFairShare > 0.02f ? w.arcFairShare : 0.12f;
float overMax = w.arcOverSharePenaltyMax > 0f ? w.arcOverSharePenaltyMax : 48f;
float underMax = w.arcUnderShareBoostMax > 0f ? w.arcUnderShareBoostMax : 22f;
float adjust = 0f;
if (share > fair)
{
float t = Mathf.Clamp01((share - fair) / Mathf.Max(0.05f, 1f - fair));
adjust -= t * overMax;
}
else
{
float t = Mathf.Clamp01((fair - share) / fair);
adjust += t * underMax;
}
if (IsCombatArc(arc))
{
float combatShare = combatCount / (float)sample;
float combatTarget = w.arcCombatShareTarget > 0.05f ? w.arcCombatShareTarget : 0.35f;
float combatPen = w.arcCombatOverSharePenaltyMax > 0f ? w.arcCombatOverSharePenaltyMax : 36f;
if (combatShare > combatTarget)
{
float t = Mathf.Clamp01((combatShare - combatTarget) / Mathf.Max(0.05f, 1f - combatTarget));
adjust -= t * combatPen;
}
}
return adjust;
}
private static bool IsCombatArc(string arc) =>
!string.IsNullOrEmpty(arc)
&& (arc.StartsWith("arc:combat:", StringComparison.OrdinalIgnoreCase)
|| string.Equals(arc, "arc:war", StringComparison.OrdinalIgnoreCase));
/// <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 (string.Equals(c.Category, "Forage", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.AssetId, "building_consume_fruit", StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(c.Label)
&& c.Label.IndexOf("foraging", StringComparison.OrdinalIgnoreCase) >= 0))
{
return "arc:forage";
}
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;
}
PushArc(arc);
}
private static void PushArc(string arc)
{
if (string.IsNullOrEmpty(arc))
{
return;
}
RecentArcs.Add(arc);
int window = Mathf.Max(4, InterestScoringConfig.W.arcFrequencyWindow > 0
? InterestScoringConfig.W.arcFrequencyWindow
: 16);
while (RecentArcs.Count > window)
{
RecentArcs.RemoveAt(0);
}
}
/// <summary>
/// True when a FixedDwell / moment beat for this candidate is still hard-cooled.
/// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked.

View file

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

View file

@ -1,7 +1,7 @@
{
"modVersion": "0.28.32",
"modVersion": "0.28.38",
"title": "IdleSpectator interest scoring model",
"updated": "2026-07-17",
"updated": "2026-07-18",
"note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
"weights": {
@ -12,6 +12,8 @@
"cutInMargin": 35,
"stickyCutInMargin": 50,
"combatUrgentEventStrengthMin": 95,
"combatUrgentCutInMargin": 20,
"stickyVarietyValveAfterSeconds": 16,
"stickyVarietyValveAfterSecondsPair": 8,
"stickyVarietyValveMaxWatchFrac": 0.4,
@ -98,6 +100,14 @@
"mixWindow": 20,
"hardCooldownSeconds": 12,
"fixedDwellBeatCooldownSeconds": 45,
"repeatArcPenaltyPer": 24,
"repeatArcPenaltyCap": 72,
"arcFrequencyWindow": 16,
"arcFairShare": 0.12,
"arcOverSharePenaltyMax": 48,
"arcUnderShareBoostMax": 22,
"arcCombatShareTarget": 0.35,
"arcCombatOverSharePenaltyMax": 36,
"enrichTopK": 8,
"metaCacheTtl": 2
},
@ -115,10 +125,13 @@
"Actions matter much more than characters.",
"Same action intensity → more important character wins (tie-break).",
"Multi-person fights outrank anonymous 1v1s.",
"Anonymous / single-notable Duels are soft sticky (short MaxWatch, early variety valve).",
"Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.",
"Live CombatActive holds the camera until the scrap goes cold (see who wins).",
"Extremely urgent peers (EventStrength >= combatUrgentEventStrengthMin, or stickyCutInMargin) may cut mid-fight.",
"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.",
"Rolling window: over-shown arcs lose score; absent/rare arcs gain score (rarer feels hotter).",
"Combat share above arcCombatShareTarget gets an extra monopoly penalty.",
"TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts."
],
"pipeline": [

View file

@ -23,6 +23,14 @@ 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.
A rolling tip window also adjusts score by frequency: over-shown arcs lose up to `arcOverSharePenaltyMax`, rare/absent arcs gain up to `arcUnderShareBoostMax`, with an extra combat monopoly penalty above `arcCombatShareTarget`.
Fruit-bush foraging is fill-band strength so it does not crowd the camera.
Live combat holds the camera until the scrap goes cold (see who wins); variety peers cut after the fight ends.
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.