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:
parent
394968a956
commit
5cdea1930a
11 changed files with 459 additions and 125 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue