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
This commit is contained in:
DazedAnon 2026-07-17 19:27:07 -05:00
parent 394968a956
commit 5cdea1930a
11 changed files with 459 additions and 125 deletions

View file

@ -2770,6 +2770,32 @@ public static class AgentHarness
break; 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": case "scoring_reload":
{ {
bool ok = InterestScoringConfig.Reload(); bool ok = InterestScoringConfig.Reload();
@ -2855,6 +2881,27 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}"); Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}");
break; 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": case "interest_mark_inactive":
{ {
float ago = ParseFloat(cmd.value, 1f); float ago = ParseFloat(cmd.value, 1f);

View file

@ -1,3 +1,4 @@
using System;
using ai.behaviours; using ai.behaviours;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
@ -37,7 +38,13 @@ public static class BuildingEventPatches
} }
string label = EventReason.BuildingEat(pActor, food); 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")] [HarmonyPatch(typeof(Building), "startDestroyBuilding")]
@ -102,7 +109,25 @@ public static class BuildingEventPatches
maxWatch: spectacle ? 28f : 18f); 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) if (!AgentHarness.LiveFeedsAllowed)
{ {
@ -113,7 +138,7 @@ public static class BuildingEventPatches
EventFeedUtil.Register( EventFeedUtil.Register(
key, key,
"building", "building",
"Spectacle", string.IsNullOrEmpty(category) ? "Spectacle" : category,
label, label,
eventId, eventId,
strength, strength,

View file

@ -59,9 +59,11 @@ internal static class HarnessScenarios
case "sticky_variety_valve": case "sticky_variety_valve":
case "variety_valve": case "variety_valve":
return StickyVarietyValve(); return StickyVarietyValve();
case "combat_hold_until_end":
case "combat_hold":
case "soft_duel_yield": case "soft_duel_yield":
case "duel_yield": case "duel_yield":
return SoftDuelYield(); return CombatHoldUntilEnd();
case "war_front_sticky": case "war_front_sticky":
case "war_sticky": case "war_sticky":
return WarFrontSticky(); return WarFrontSticky();
@ -218,7 +220,7 @@ internal static class HarnessScenarios
Nested("reg_director_gaps", "director_gaps"), Nested("reg_director_gaps", "director_gaps"),
Nested("reg_action_priority", "director_action_priority"), Nested("reg_action_priority", "director_action_priority"),
Nested("reg_variety_valve", "sticky_variety_valve"), 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_combat_stability", "combat_stability_live"),
Nested("reg_discovery", "discovery"), Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"), Nested("reg_worldlog", "world_log"),
@ -1316,7 +1318,7 @@ internal static class HarnessScenarios
} }
/// <summary> /// <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> /// </summary>
private static List<HarnessCommand> StickyVarietyValve() 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"), expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("sv24b", "assert", expect: "pending_contains", value: "valve_lover"), Step("sv24b", "assert", expect: "pending_contains", value: "valve_lover"),
Step("sv25", "director_run", wait: 1.2f), Step("sv25", "director_run", wait: 1.2f),
Step("sv26", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"), // Live scrap: hold through the peer (see who wins).
Step("sv27", "assert", expect: "tip_not_contains", value: "Mass -"), Step("sv26", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
Step("sv28", "assert", expect: "tip_asset", value: "set_lover"), 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("sv90", "fast_timing", value: "false"),
Step("sv99", "snapshot"), Step("sv99", "snapshot"),
@ -1354,49 +1366,75 @@ internal static class HarnessScenarios
} }
/// <summary> /// <summary>
/// Anonymous pair Duels are soft sticky: after a short hold, a Story/lover peer may cut in /// Live Duels hold through ordinary peers; catalog epic-world urgency may cut mid-fight;
/// without needing Mass-tier sticky margin. /// after the scrap goes cold, Story peers may cut.
/// </summary> /// </summary>
private static List<HarnessCommand> SoftDuelYield() private static List<HarnessCommand> CombatHoldUntilEnd()
{ {
return new List<HarnessCommand> return new List<HarnessCommand>
{ {
Step("sd0", "dismiss_windows"), Step("ch0", "dismiss_windows"),
Step("sd1", "wait_world"), Step("ch1", "wait_world"),
Step("sd2", "set_setting", expect: "enabled", value: "true"), Step("ch2", "set_setting", expect: "enabled", value: "true"),
Step("sd3", "fast_timing", value: "true"), Step("ch3", "fast_timing", value: "true"),
Step("sd4", "spawn", asset: "human", count: 1), Step("ch4", "spawn", asset: "human", count: 1),
Step("sd5", "spawn", asset: "wolf", count: 1), Step("ch5", "spawn", asset: "wolf", count: 1),
Step("sd6", "spectator", value: "off"), Step("ch6", "spectator", value: "off"),
Step("sd7", "spectator", value: "on"), Step("ch7", "spectator", value: "on"),
Step("sd8", "pick_unit", asset: "human"), Step("ch8", "pick_unit", asset: "human"),
Step("sd9", "focus", asset: "human"), Step("ch9", "focus", asset: "human"),
Step("sd10", "interest_end_session"), Step("ch10", "interest_end_session"),
// Do not combat_wire_attack_sides - that absorbs leftover same-species fighters into Battle. // Wire attack so CombatStillActive stays hot; invincible so the pair survives the assert.
// "no_attack" in expect skips attack wiring (keeps pair alive); tip still uses Duel framing. // Isolate so leftover world packs cannot escalate a 1v1 into Battle mid-hold.
Step("sd20", "interest_combat_session", asset: "human", value: "wolf", Step("ch20", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack"),
expect: "sd_pack no_attack"), Step("ch20b", "combat_isolate_pair", value: "20"),
Step("sd21b", "status_apply", asset: "human", value: "invincible"), Step("ch21b", "status_apply", asset: "human", value: "invincible"),
Step("sd21c", "status_apply", asset: "wolf", value: "invincible"), Step("ch21c", "status_apply", asset: "wolf", value: "invincible"),
Step("sd22", "assert", expect: "tip_matches_any", value: "Duel -"), Step("ch22", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("sd23", "assert", expect: "tip_not_contains", value: "Mass -"), Step("ch23", "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 -"),
Step("sd29", "interest_expire_pending", value: ""), Step("ch29", "interest_expire_pending", value: ""),
Step("sd30", "age_current", wait: 9f), Step("ch30", "age_current", wait: 9f),
Step("sd30b", "combat_maintain_focus"), Step("ch30b", "combat_maintain_focus"),
Step("sd31", "interest_inject", asset: "human", label: "falls in love", tier: "Action", Step("ch30c", "assert", expect: "tip_matches_any", value: "Duel -"),
expect: "sd_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"), // Ordinary Story/Action peer: hold the scrap.
Step("sd32", "assert", expect: "pending_contains", value: "sd_lover"), Step("ch31", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
Step("sd33", "director_run", wait: 1.2f), expect: "ch_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
Step("sd34", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"), Step("ch32", "assert", expect: "pending_contains", value: "ch_lover"),
Step("sd35", "assert", expect: "tip_not_contains", value: "Duel -"), Step("ch33", "director_run", wait: 1.2f),
Step("sd36", "assert", expect: "tip_asset", value: "set_lover"), 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"), // Extremely urgent (catalog epic-world EventStrength) may cut mid-fight.
Step("sd99", "snapshot"), 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"),
}; };
} }
@ -1952,6 +1990,16 @@ internal static class HarnessScenarios
value: "lead=event;evt=78;char=10"), value: "lead=event;evt=78;char=10"),
Step("ap56", "assert", expect: "interest_score_order", value: "fresh_story", label: "repeat_duel"), 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. // 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

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

View file

@ -47,6 +47,11 @@ public static class InterestDirector
private static float _lastCombatFocusAt = -999f; private static float _lastCombatFocusAt = -999f;
/// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary> /// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary>
private static bool _varietyValveUsed; 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; private const float CombatFocusThrottleSeconds = 1.25f;
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary> /// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
private const float CombatFocusThrottleLargeSeconds = 2.0f; private const float CombatFocusThrottleLargeSeconds = 2.0f;
@ -268,6 +273,33 @@ public static class InterestDirector
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds); _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> /// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
public static bool HarnessClearFollowForHandoff(Actor related) public static bool HarnessClearFollowForHandoff(Actor related)
{ {
@ -439,8 +471,9 @@ public static class InterestDirector
return; return;
} }
// Max cap ends the scene even if still "active". // Max cap ends the scene even if still "active" - except live combat, which
if (onCurrent >= MaxWatchFor(_current)) // 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"); EndCurrent(now, reason: "max_cap");
return; return;
@ -3217,6 +3250,7 @@ public static class InterestDirector
_interrupted = null; _interrupted = null;
_current = null; _current = null;
_harnessCombatForcedCold = false;
_inactiveSince = -999f; _inactiveSince = -999f;
EnsureIdleFocus(now); EnsureIdleFocus(now);
} }
@ -3542,9 +3576,7 @@ public static class InterestDirector
switch (current.Completion) switch (current.Completion)
{ {
case InterestCompletionKind.CombatActive: case InterestCompletionKind.CombatActive:
classCap = IsSoftStickyCombat(current) classCap = w.maxWatchCombat;
? (w.maxWatchDuel > 0f ? w.maxWatchDuel : 24f)
: w.maxWatchCombat;
break; break;
case InterestCompletionKind.WarFront: case InterestCompletionKind.WarFront:
case InterestCompletionKind.PlotActive: case InterestCompletionKind.PlotActive:
@ -3580,15 +3612,9 @@ public static class InterestDirector
float cap = classCap; float cap = classCap;
if (current.MaxWatch > 0f) 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 // 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. // 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.WarFront
|| current.Completion == InterestCompletionKind.PlotActive || current.Completion == InterestCompletionKind.PlotActive
|| current.Completion == InterestCompletionKind.StatusPhase || current.Completion == InterestCompletionKind.StatusPhase
@ -3701,6 +3727,27 @@ public static class InterestDirector
} }
float now = Time.unscaledTime; 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; bool inGrace = InQuietGrace;
if (inGrace) if (inGrace)
{ {
@ -3771,33 +3818,17 @@ public static class InterestDirector
return true; 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) if (IsSoftStickyCluster(_current)
&& !IsSoftStickyCluster(candidate) && !IsSoftStickyCluster(candidate)
&& !nextFill && !nextFill
&& !IsAmbientShot(candidate) && !IsAmbientShot(candidate)
&& onCurrent >= SoftYieldSecondsFor(_current) && onCurrent >= SoftClusterYieldSeconds
&& nextScore >= curScore - w.rotateSlack) && nextScore >= curScore - w.rotateSlack)
{ {
return true; 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. // 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. // Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
if (varietyValve && !nextFill && !IsAmbientShot(candidate)) 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> /// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
private const float SoftClusterYieldSeconds = 4f; 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) => private static bool IsSoftStickyCluster(InterestCandidate c) =>
c != null c != null && c.Completion == InterestCompletionKind.FamilyPack;
&& (c.Completion == InterestCompletionKind.FamilyPack || IsSoftStickyCombat(c));
/// <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> /// <summary>
/// Pair scrap without two notables - soft sticky (normal cut-in, short MaxWatch, early valve). /// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
/// Hard sticky reserved for Skirmish+ (<c>live_battle</c>), larger rosters, or notable-pair Duels. /// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
/// Do not use ContestedFighters here: lopsided Mass tips can stamp sides as 1v1 while the roster is large.
/// </summary> /// </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; return false;
} }
// Ensemble framing (Skirmish/Battle/Mass) always hard-holds. if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (c.NotableParticipantCount >= 2)
{ {
return false; return false;
} }
ScoringWeights w = InterestScoringConfig.W; ScoringWeights w = InterestScoringConfig.W;
int sideSum = Math.Max(0, c.CombatSideACount) + Math.Max(0, c.CombatSideBCount); float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
int size = Math.Max(Math.Max(0, c.ParticipantCount), sideSum); if (next.TotalScore >= current.TotalScore + stickyMargin)
int skirmishFloor = Math.Max(3, w.skirmishMinFighters);
if (size >= skirmishFloor)
{ {
return false; return true;
} }
int duelMax = Math.Max(1, w.duelMaxFighters); float epicMin = w.combatUrgentEventStrengthMin > 0f
// size 0 during attack_target gaps: keep soft if we never escalated past a pair. ? w.combatUrgentEventStrengthMin
if (size == 0) : 95f;
{ float urgentMargin = w.combatUrgentCutInMargin > 0f
return c.CombatPeakParticipants <= duelMax; ? w.combatUrgentCutInMargin
} : 20f;
return next.EventStrength >= epicMin
return size <= duelMax; && next.TotalScore >= current.TotalScore + urgentMargin;
} }
private static float SoftYieldSecondsFor(InterestCandidate c) =>
IsSoftStickyCombat(c) ? SoftCombatYieldSeconds : SoftClusterYieldSeconds;
/// <summary> /// <summary>
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in. /// 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> /// </summary>
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) => 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) private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent)
{ {
@ -3937,11 +3971,7 @@ public static class InterestDirector
} }
ScoringWeights w = InterestScoringConfig.W; ScoringWeights w = InterestScoringConfig.W;
float after = IsSoftStickyCombat(scene) float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f;
? (w.stickyVarietyValveAfterSecondsPair > 0f
? w.stickyVarietyValveAfterSecondsPair
: 8f)
: (w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f);
if (onCurrent >= after) if (onCurrent >= after)
{ {
return true; return true;
@ -4472,6 +4502,7 @@ public static class InterestDirector
_currentStartedAt = now; _currentStartedAt = now;
_lastSwitchAt = now; _lastSwitchAt = now;
_inactiveSince = -999f; _inactiveSince = -999f;
_harnessCombatForcedCold = false;
if (!InterestScoring.IsFillScore(next.TotalScore)) if (!InterestScoring.IsFillScore(next.TotalScore))
{ {
_lastInterestingAt = now; _lastInterestingAt = now;

View file

@ -158,14 +158,28 @@ public static class InterestScoring
} }
float repeatPenalty = InterestVariety.RepeatArcPenalty(c); float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(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; - repeatPenalty
c.ScoreDetail = + frequencyAdjust;
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}" string detail =
+ (repeatPenalty > 0.05f ? $" rpt=-{repeatPenalty:0.#}" : ""); $"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> /// <summary>

View file

@ -30,6 +30,16 @@ public class ScoringWeights
public float cutInMargin = 35f; public float cutInMargin = 35f;
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary> /// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
public float stickyCutInMargin = 50f; 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> /// <summary>After this many seconds on sticky combat/war, variety-valve peers may cut in.</summary>
public float stickyVarietyValveAfterSeconds = 16f; public float stickyVarietyValveAfterSeconds = 16f;
/// <summary> /// <summary>
@ -143,6 +153,18 @@ public class ScoringWeights
public float repeatArcPenaltyPer = 24f; public float repeatArcPenaltyPer = 24f;
/// <summary>Cap on stacked repeat-arc penalty.</summary> /// <summary>Cap on stacked repeat-arc penalty.</summary>
public float repeatArcPenaltyCap = 72f; 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 int enrichTopK = 8;
public float metaCacheTtl = 2f; public float metaCacheTtl = 2f;
} }

View file

@ -6,8 +6,8 @@ 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,
/// hard FixedDwell beat cooldowns, and consecutive variety-arc score drop-off /// hard FixedDwell beat cooldowns, consecutive arc drop-off, and a rolling
/// (same arc loses TotalScore until a different arc is shown). /// rarity window (common arcs lose score; rare/absent arcs gain score).
/// </summary> /// </summary>
public static class InterestVariety public static class InterestVariety
{ {
@ -18,6 +18,7 @@ public static class InterestVariety
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32); 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 Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
private static readonly System.Random Rng = new System.Random(); private static readonly System.Random Rng = new System.Random();
@ -37,15 +38,34 @@ public static class InterestVariety
/// <summary>Harness/debug: consecutive shows of <see cref="LastArcKey"/>.</summary> /// <summary>Harness/debug: consecutive shows of <see cref="LastArcKey"/>.</summary>
public static int ArcStreak => _arcStreak; 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() public static void Clear()
{ {
RecentMix.Clear(); RecentMix.Clear();
RecentArcs.Clear();
CooldownUntil.Clear(); CooldownUntil.Clear();
LastPickHadBothPools = false; LastPickHadBothPools = false;
_lastArcKey = ""; _lastArcKey = "";
_arcStreak = 0; _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> /// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary>
public static void HarnessSeedMix(int eventLedCount, int characterLedCount) public static void HarnessSeedMix(int eventLedCount, int characterLedCount)
{ {
@ -158,6 +178,85 @@ public static class InterestVariety
return Mathf.Min(cap, _arcStreak * per); 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> /// <summary>
/// Class-level arc for repeat drop-off (not per unit / pair id). /// 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. /// Duels share one arc so back-to-back scraps lose score; Mass/Battle share another.
@ -175,6 +274,14 @@ public static class InterestVariety
return "arc:war"; 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 if (c.Completion == InterestCompletionKind.PlotActive
|| string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase)) || string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase))
{ {
@ -287,6 +394,25 @@ public static class InterestVariety
_lastArcKey = arc; _lastArcKey = arc;
_arcStreak = 1; _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> /// <summary>

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.28.34", "version": "0.28.38",
"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,7 +1,7 @@
{ {
"modVersion": "0.28.34", "modVersion": "0.28.38",
"title": "IdleSpectator interest scoring model", "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).", "note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
"weights": { "weights": {
@ -12,6 +12,8 @@
"cutInMargin": 35, "cutInMargin": 35,
"stickyCutInMargin": 50, "stickyCutInMargin": 50,
"combatUrgentEventStrengthMin": 95,
"combatUrgentCutInMargin": 20,
"stickyVarietyValveAfterSeconds": 16, "stickyVarietyValveAfterSeconds": 16,
"stickyVarietyValveAfterSecondsPair": 8, "stickyVarietyValveAfterSecondsPair": 8,
"stickyVarietyValveMaxWatchFrac": 0.4, "stickyVarietyValveMaxWatchFrac": 0.4,
@ -100,6 +102,12 @@
"fixedDwellBeatCooldownSeconds": 45, "fixedDwellBeatCooldownSeconds": 45,
"repeatArcPenaltyPer": 24, "repeatArcPenaltyPer": 24,
"repeatArcPenaltyCap": 72, "repeatArcPenaltyCap": 72,
"arcFrequencyWindow": 16,
"arcFairShare": 0.12,
"arcOverSharePenaltyMax": 48,
"arcUnderShareBoostMax": 22,
"arcCombatShareTarget": 0.35,
"arcCombatOverSharePenaltyMax": 36,
"enrichTopK": 8, "enrichTopK": 8,
"metaCacheTtl": 2 "metaCacheTtl": 2
}, },
@ -117,11 +125,13 @@
"Actions matter much more than characters.", "Actions matter much more than characters.",
"Same action intensity → more important character wins (tie-break).", "Same action intensity → more important character wins (tie-break).",
"Multi-person fights outrank anonymous 1v1s.", "Multi-person fights outrank anonymous 1v1s.",
"Anonymous / single-notable Duels are soft sticky (short MaxWatch, early variety valve).", "Live CombatActive holds the camera until the scrap goes cold (see who wins).",
"Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.", "Extremely urgent peers (EventStrength >= combatUrgentEventStrengthMin, or stickyCutInMargin) may cut mid-fight.",
"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.", "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." "TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts."
], ],
"pipeline": [ "pipeline": [

View file

@ -25,6 +25,12 @@ Founding tips come from `new_city` / `new_kingdom` meta outcomes, not decision i
Consecutive shows of the same variety arc (e.g. duel → duel) subtract `repeatArcPenaltyPer` from TotalScore (capped), resetting when a different arc is shown. 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 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.