Compare commits
2 commits
7d23f590db
...
5cdea1930a
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cdea1930a | |||
| 394968a956 |
11 changed files with 720 additions and 124 deletions
|
|
@ -2732,6 +2732,70 @@ public static class AgentHarness
|
|||
Emit(cmd, ok: true, detail: "variety_cleared");
|
||||
break;
|
||||
|
||||
case "interest_variety_note":
|
||||
{
|
||||
// Stamp the current (or pending needle in value) as the last shown variety arc.
|
||||
InterestCandidate c = InterestDirector.CurrentCandidate;
|
||||
string needle = (cmd.value ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(needle))
|
||||
{
|
||||
var pending = new System.Collections.Generic.List<InterestCandidate>(64);
|
||||
InterestRegistry.CopyPending(pending);
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
{
|
||||
InterestCandidate p = pending[i];
|
||||
if (p?.Key != null
|
||||
&& p.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (c == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no candidate to note");
|
||||
break;
|
||||
}
|
||||
|
||||
InterestVariety.NoteSelection(c, updateMix: false);
|
||||
InterestScoring.RecalcTotal(c);
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"arc={InterestVariety.LastArcKey} streak={InterestVariety.ArcStreak} key={c.Key}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_variety_push_arc":
|
||||
{
|
||||
// value = arc key (or short "combat:duel"), label/count = times
|
||||
string arc = (cmd.value ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(arc) && !arc.StartsWith("arc:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
arc = "arc:" + arc;
|
||||
}
|
||||
|
||||
int times = cmd.count > 0 ? cmd.count : ParseInt(cmd.label, 8);
|
||||
if (string.IsNullOrEmpty(arc))
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no arc");
|
||||
break;
|
||||
}
|
||||
|
||||
InterestVariety.HarnessPushArc(arc, times);
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"pushed={arc} x{times} window={InterestVariety.ArcWindowCount}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "scoring_reload":
|
||||
{
|
||||
bool ok = InterestScoringConfig.Reload();
|
||||
|
|
@ -2817,6 +2881,27 @@ public static class AgentHarness
|
|||
Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}");
|
||||
break;
|
||||
|
||||
case "interest_mark_combat_cold":
|
||||
{
|
||||
// Drop attack/combat signals so live path cannot refresh hot; force-cold gates the hold.
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (scene != null)
|
||||
{
|
||||
ClearAttackTarget(scene.FollowUnit);
|
||||
ClearAttackTarget(scene.RelatedUnit);
|
||||
TryClearCombatTask(scene.FollowUnit);
|
||||
TryClearCombatTask(scene.RelatedUnit);
|
||||
}
|
||||
|
||||
InterestDirector.HarnessMarkCombatCold();
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"combat_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_mark_inactive":
|
||||
{
|
||||
float ago = ParseFloat(cmd.value, 1f);
|
||||
|
|
@ -6996,8 +7081,35 @@ public static class AgentHarness
|
|||
}
|
||||
}
|
||||
|
||||
if (win != null)
|
||||
{
|
||||
InterestScoring.RecalcTotal(win);
|
||||
}
|
||||
|
||||
if (lose != null)
|
||||
{
|
||||
InterestScoring.RecalcTotal(lose);
|
||||
}
|
||||
|
||||
pass = win != null && lose != null && win.TotalScore >= lose.TotalScore;
|
||||
detail = $"win={win?.Key}({win?.TotalScore:0.#}) lose={lose?.Key}({lose?.TotalScore:0.#})";
|
||||
detail = $"win={win?.Key}({win?.TotalScore:0.#}) lose={lose?.Key}({lose?.TotalScore:0.#})"
|
||||
+ $" arc={InterestVariety.LastArcKey} streak={InterestVariety.ArcStreak}";
|
||||
break;
|
||||
}
|
||||
case "variety_arc":
|
||||
{
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
string have = InterestVariety.LastArcKey ?? "";
|
||||
pass = !string.IsNullOrEmpty(want)
|
||||
&& have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
detail = $"arc='{have}' want='{want}' streak={InterestVariety.ArcStreak}";
|
||||
break;
|
||||
}
|
||||
case "variety_arc_streak":
|
||||
{
|
||||
int want = ParseInt(cmd.value, 1);
|
||||
pass = InterestVariety.ArcStreak == want;
|
||||
detail = $"streak={InterestVariety.ArcStreak} want={want} arc={InterestVariety.LastArcKey}";
|
||||
break;
|
||||
}
|
||||
case "civic_boost":
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using ai.behaviours;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
|
@ -37,7 +38,13 @@ public static class BuildingEventPatches
|
|||
}
|
||||
|
||||
string label = EventReason.BuildingEat(pActor, food);
|
||||
Emit("building_consume", 78f, pActor, label);
|
||||
bool fruitForage = IsFruitForage(food);
|
||||
// Fruit bushes are high-rate ambient - fill-band strength so they do not crowd combat/story.
|
||||
// Real settlement eats (granary/etc.) stay camera-competitive.
|
||||
float strength = fruitForage ? 42f : 78f;
|
||||
string category = fruitForage ? "Forage" : "Spectacle";
|
||||
string assetId = fruitForage ? "building_consume_fruit" : "building_consume";
|
||||
Emit(assetId, strength, category, pActor, label);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Building), "startDestroyBuilding")]
|
||||
|
|
@ -102,7 +109,25 @@ public static class BuildingEventPatches
|
|||
maxWatch: spectacle ? 28f : 18f);
|
||||
}
|
||||
|
||||
private static void Emit(string eventId, float strength, Actor actor, string label)
|
||||
private static bool IsFruitForage(Building food)
|
||||
{
|
||||
if (food?.asset == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string type = food.asset.type ?? "";
|
||||
return string.Equals(type, "type_fruits", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Emit(string eventId, float strength, string category, Actor actor, string label)
|
||||
{
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
|
|
@ -113,7 +138,7 @@ public static class BuildingEventPatches
|
|||
EventFeedUtil.Register(
|
||||
key,
|
||||
"building",
|
||||
"Spectacle",
|
||||
string.IsNullOrEmpty(category) ? "Spectacle" : category,
|
||||
label,
|
||||
eventId,
|
||||
strength,
|
||||
|
|
|
|||
|
|
@ -59,9 +59,11 @@ internal static class HarnessScenarios
|
|||
case "sticky_variety_valve":
|
||||
case "variety_valve":
|
||||
return StickyVarietyValve();
|
||||
case "combat_hold_until_end":
|
||||
case "combat_hold":
|
||||
case "soft_duel_yield":
|
||||
case "duel_yield":
|
||||
return SoftDuelYield();
|
||||
return CombatHoldUntilEnd();
|
||||
case "war_front_sticky":
|
||||
case "war_sticky":
|
||||
return WarFrontSticky();
|
||||
|
|
@ -218,7 +220,7 @@ internal static class HarnessScenarios
|
|||
Nested("reg_director_gaps", "director_gaps"),
|
||||
Nested("reg_action_priority", "director_action_priority"),
|
||||
Nested("reg_variety_valve", "sticky_variety_valve"),
|
||||
Nested("reg_soft_duel_yield", "soft_duel_yield"),
|
||||
Nested("reg_combat_hold", "combat_hold_until_end"),
|
||||
Nested("reg_combat_stability", "combat_stability_live"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
|
|
@ -1316,7 +1318,7 @@ internal static class HarnessScenarios
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// After sticky combat matures, a Story/lover peer may cut in via the variety valve.
|
||||
/// Live Mass combat holds through story peers; after the scrap goes cold, variety valve may cut.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StickyVarietyValve()
|
||||
{
|
||||
|
|
@ -1344,9 +1346,19 @@ internal static class HarnessScenarios
|
|||
expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
|
||||
Step("sv24b", "assert", expect: "pending_contains", value: "valve_lover"),
|
||||
Step("sv25", "director_run", wait: 1.2f),
|
||||
Step("sv26", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
|
||||
Step("sv27", "assert", expect: "tip_not_contains", value: "Mass -"),
|
||||
Step("sv28", "assert", expect: "tip_asset", value: "set_lover"),
|
||||
// Live scrap: hold through the peer (see who wins).
|
||||
Step("sv26", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
Step("sv27", "assert", expect: "tip_not_contains", value: "falls in love"),
|
||||
|
||||
// Fight over → story peer may cut. Clear discovery noise so the lover is the peer under test.
|
||||
Step("sv28", "interest_expire_pending", value: ""),
|
||||
Step("sv29", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
|
||||
expect: "valve_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
|
||||
Step("sv30", "interest_mark_combat_cold"),
|
||||
Step("sv31", "director_run", wait: 1.2f),
|
||||
Step("sv32", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
|
||||
Step("sv33", "assert", expect: "tip_not_contains", value: "Mass -"),
|
||||
Step("sv34", "assert", expect: "tip_asset", value: "set_lover"),
|
||||
|
||||
Step("sv90", "fast_timing", value: "false"),
|
||||
Step("sv99", "snapshot"),
|
||||
|
|
@ -1354,49 +1366,75 @@ internal static class HarnessScenarios
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous pair Duels are soft sticky: after a short hold, a Story/lover peer may cut in
|
||||
/// without needing Mass-tier sticky margin.
|
||||
/// Live Duels hold through ordinary peers; catalog epic-world urgency may cut mid-fight;
|
||||
/// after the scrap goes cold, Story peers may cut.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> SoftDuelYield()
|
||||
private static List<HarnessCommand> CombatHoldUntilEnd()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sd0", "dismiss_windows"),
|
||||
Step("sd1", "wait_world"),
|
||||
Step("sd2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sd3", "fast_timing", value: "true"),
|
||||
Step("sd4", "spawn", asset: "human", count: 1),
|
||||
Step("sd5", "spawn", asset: "wolf", count: 1),
|
||||
Step("sd6", "spectator", value: "off"),
|
||||
Step("sd7", "spectator", value: "on"),
|
||||
Step("sd8", "pick_unit", asset: "human"),
|
||||
Step("sd9", "focus", asset: "human"),
|
||||
Step("sd10", "interest_end_session"),
|
||||
Step("ch0", "dismiss_windows"),
|
||||
Step("ch1", "wait_world"),
|
||||
Step("ch2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ch3", "fast_timing", value: "true"),
|
||||
Step("ch4", "spawn", asset: "human", count: 1),
|
||||
Step("ch5", "spawn", asset: "wolf", count: 1),
|
||||
Step("ch6", "spectator", value: "off"),
|
||||
Step("ch7", "spectator", value: "on"),
|
||||
Step("ch8", "pick_unit", asset: "human"),
|
||||
Step("ch9", "focus", asset: "human"),
|
||||
Step("ch10", "interest_end_session"),
|
||||
|
||||
// Do not combat_wire_attack_sides - that absorbs leftover same-species fighters into Battle.
|
||||
// "no_attack" in expect skips attack wiring (keeps pair alive); tip still uses Duel framing.
|
||||
Step("sd20", "interest_combat_session", asset: "human", value: "wolf",
|
||||
expect: "sd_pack no_attack"),
|
||||
Step("sd21b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("sd21c", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("sd22", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("sd23", "assert", expect: "tip_not_contains", value: "Mass -"),
|
||||
Step("sd24", "assert", expect: "tip_not_contains", value: "Battle -"),
|
||||
Step("sd25", "assert", expect: "tip_not_contains", value: "Skirmish -"),
|
||||
// Wire attack so CombatStillActive stays hot; invincible so the pair survives the assert.
|
||||
// Isolate so leftover world packs cannot escalate a 1v1 into Battle mid-hold.
|
||||
Step("ch20", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack"),
|
||||
Step("ch20b", "combat_isolate_pair", value: "20"),
|
||||
Step("ch21b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("ch21c", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("ch22", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ch23", "assert", expect: "tip_not_contains", value: "Mass -"),
|
||||
|
||||
Step("sd29", "interest_expire_pending", value: ""),
|
||||
Step("sd30", "age_current", wait: 9f),
|
||||
Step("sd30b", "combat_maintain_focus"),
|
||||
Step("sd31", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
|
||||
expect: "sd_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
|
||||
Step("sd32", "assert", expect: "pending_contains", value: "sd_lover"),
|
||||
Step("sd33", "director_run", wait: 1.2f),
|
||||
Step("sd34", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
|
||||
Step("sd35", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||
Step("sd36", "assert", expect: "tip_asset", value: "set_lover"),
|
||||
Step("ch29", "interest_expire_pending", value: ""),
|
||||
Step("ch30", "age_current", wait: 9f),
|
||||
Step("ch30b", "combat_maintain_focus"),
|
||||
Step("ch30c", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
// Ordinary Story/Action peer: hold the scrap.
|
||||
Step("ch31", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
|
||||
expect: "ch_lover", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
|
||||
Step("ch32", "assert", expect: "pending_contains", value: "ch_lover"),
|
||||
Step("ch33", "director_run", wait: 1.2f),
|
||||
Step("ch34", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ch35", "assert", expect: "tip_not_contains", value: "falls in love"),
|
||||
|
||||
Step("sd90", "fast_timing", value: "false"),
|
||||
Step("sd99", "snapshot"),
|
||||
// Extremely urgent (catalog epic-world EventStrength) may cut mid-fight.
|
||||
Step("ch36", "interest_expire_pending", value: "ch_lover"),
|
||||
Step("ch37", "interest_inject", asset: "human", label: "UrgentCut", tier: "Epic",
|
||||
expect: "ch_urgent", value: "lead=event;evt=150;char=10;force=false;ttl=60"),
|
||||
Step("ch38", "assert", expect: "pending_contains", value: "ch_urgent"),
|
||||
Step("ch39", "director_run", wait: 1.2f),
|
||||
Step("ch39b", "assert", expect: "tip_contains", value: "UrgentCut"),
|
||||
Step("ch39c", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||
|
||||
// Fresh scrap: after cold, ordinary peers may cut again.
|
||||
Step("ch40", "interest_end_session"),
|
||||
Step("ch41", "interest_expire_pending", value: ""),
|
||||
Step("ch42", "interest_combat_session", asset: "human", value: "wolf", expect: "ch_pack2"),
|
||||
Step("ch42b", "combat_isolate_pair", value: "20"),
|
||||
Step("ch42c", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("ch42d", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("ch43", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ch44", "interest_inject", asset: "human", label: "falls in love", tier: "Action",
|
||||
expect: "ch_lover2", value: "lead=event;evt=78;char=10;force=false;ttl=60"),
|
||||
Step("ch45", "director_run", wait: 1.2f),
|
||||
Step("ch46", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ch47", "interest_mark_combat_cold"),
|
||||
Step("ch48", "director_run", wait: 1.2f),
|
||||
Step("ch49", "assert", expect: "tip_matches_any", value: "love|lover|falls in|in scene"),
|
||||
Step("ch50", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||
Step("ch51", "assert", expect: "tip_asset", value: "set_lover"),
|
||||
|
||||
Step("ch90", "fast_timing", value: "false"),
|
||||
Step("ch99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1939,6 +1977,29 @@ internal static class HarnessScenarios
|
|||
Step("ap44", "assert", expect: "tip_contains", value: "RealFight"),
|
||||
Step("ap45", "assert", expect: "tip_not_contains", value: "KingStroll"),
|
||||
|
||||
// Consecutive same variety arc stacks a score penalty until a different arc shows.
|
||||
Step("ap50", "interest_variety_clear"),
|
||||
Step("ap50b", "interest_expire_pending", value: ""),
|
||||
Step("ap51", "interest_inject", asset: "sheep", label: "FirstDuel", tier: "Action", expect: "first_duel",
|
||||
value: "lead=event;evt=88;char=5;fighters=2;notables=0;force=true"),
|
||||
Step("ap52", "assert", expect: "variety_arc", value: "combat:duel"),
|
||||
Step("ap53", "assert", expect: "variety_arc_streak", value: "1"),
|
||||
Step("ap54", "interest_inject", asset: "sheep", label: "RepeatDuel", tier: "Action", expect: "repeat_duel",
|
||||
value: "lead=event;evt=100;char=5;fighters=2;notables=0"),
|
||||
Step("ap55", "interest_inject", asset: "sheep", label: "FreshStory", tier: "Action", expect: "fresh_story",
|
||||
value: "lead=event;evt=78;char=10"),
|
||||
Step("ap56", "assert", expect: "interest_score_order", value: "fresh_story", label: "repeat_duel"),
|
||||
|
||||
// Rolling rarity: flood the window with duels → a rare spectacle outranks another duel.
|
||||
Step("ap60", "interest_variety_clear"),
|
||||
Step("ap60b", "interest_expire_pending", value: ""),
|
||||
Step("ap61", "interest_variety_push_arc", value: "combat:duel", count: 12),
|
||||
Step("ap62", "interest_inject", asset: "sheep", label: "CommonDuel", tier: "Action", expect: "common_duel",
|
||||
value: "lead=event;evt=100;char=5;fighters=2;notables=0"),
|
||||
Step("ap63", "interest_inject", asset: "sheep", label: "RareSpell", tier: "Action", expect: "rare_spell",
|
||||
value: "lead=event;evt=86;char=10"),
|
||||
Step("ap64", "assert", expect: "interest_score_order", value: "rare_spell", label: "common_duel"),
|
||||
|
||||
// Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
|
||||
Step("ap89", "reset_counters"),
|
||||
Step("ap90", "assert", expect: "no_bad"),
|
||||
|
|
|
|||
|
|
@ -89,6 +89,11 @@ public static class InterestCompletion
|
|||
|
||||
private static bool CombatStillActive(InterestCandidate c)
|
||||
{
|
||||
if (InterestDirector.HarnessCombatForcedCold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Actor unit = c.FollowUnit;
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ public static class InterestDirector
|
|||
private static float _lastCombatFocusAt = -999f;
|
||||
/// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary>
|
||||
private static bool _varietyValveUsed;
|
||||
/// <summary>
|
||||
/// Harness-only: treat the current CombatActive scene as cold so peers can cut after a fight
|
||||
/// without waiting for WorldBox to drop attack_target / combat tasks.
|
||||
/// </summary>
|
||||
private static bool _harnessCombatForcedCold;
|
||||
private const float CombatFocusThrottleSeconds = 1.25f;
|
||||
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
|
||||
private const float CombatFocusThrottleLargeSeconds = 2.0f;
|
||||
|
|
@ -268,6 +273,33 @@ public static class InterestDirector
|
|||
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: force the current combat scene cold (fight over) without ending the session,
|
||||
/// so quiet-grace / variety-valve peers may cut after a live hold.
|
||||
/// </summary>
|
||||
/// <summary>Harness: current CombatActive is forced cold until the next scene switch.</summary>
|
||||
public static bool HarnessCombatForcedCold => _harnessCombatForcedCold;
|
||||
|
||||
public static void HarnessMarkCombatCold()
|
||||
{
|
||||
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
_harnessCombatForcedCold = true;
|
||||
_current.ForceActive = false;
|
||||
_current.LastSeenAt = now - 60f;
|
||||
// Skip HasLiveCombatNear sticky path while proving post-fight cuts.
|
||||
if (string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_current.AssetId = "live_combat";
|
||||
}
|
||||
|
||||
_inactiveSince = now;
|
||||
}
|
||||
|
||||
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
|
||||
public static bool HarnessClearFollowForHandoff(Actor related)
|
||||
{
|
||||
|
|
@ -439,8 +471,9 @@ public static class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
// Max cap ends the scene even if still "active".
|
||||
if (onCurrent >= MaxWatchFor(_current))
|
||||
// Max cap ends the scene even if still "active" - except live combat, which
|
||||
// holds until the scrap goes cold so the viewer can see who wins.
|
||||
if (onCurrent >= MaxWatchFor(_current) && !CombatFightIsHot(_current, now))
|
||||
{
|
||||
EndCurrent(now, reason: "max_cap");
|
||||
return;
|
||||
|
|
@ -3217,6 +3250,7 @@ public static class InterestDirector
|
|||
|
||||
_interrupted = null;
|
||||
_current = null;
|
||||
_harnessCombatForcedCold = false;
|
||||
_inactiveSince = -999f;
|
||||
EnsureIdleFocus(now);
|
||||
}
|
||||
|
|
@ -3542,9 +3576,7 @@ public static class InterestDirector
|
|||
switch (current.Completion)
|
||||
{
|
||||
case InterestCompletionKind.CombatActive:
|
||||
classCap = IsSoftStickyCombat(current)
|
||||
? (w.maxWatchDuel > 0f ? w.maxWatchDuel : 24f)
|
||||
: w.maxWatchCombat;
|
||||
classCap = w.maxWatchCombat;
|
||||
break;
|
||||
case InterestCompletionKind.WarFront:
|
||||
case InterestCompletionKind.PlotActive:
|
||||
|
|
@ -3580,15 +3612,9 @@ public static class InterestDirector
|
|||
float cap = classCap;
|
||||
if (current.MaxWatch > 0f)
|
||||
{
|
||||
// Soft pair Duels: honor maxWatchDuel - scanner/harness often stamp MaxWatch=60.
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
&& IsSoftStickyCombat(current))
|
||||
{
|
||||
cap = classCap;
|
||||
}
|
||||
// Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a
|
||||
// short candidate MaxWatch max_cap the fight while it is still active.
|
||||
else if (current.Completion == InterestCompletionKind.CombatActive
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
|| current.Completion == InterestCompletionKind.WarFront
|
||||
|| current.Completion == InterestCompletionKind.PlotActive
|
||||
|| current.Completion == InterestCompletionKind.StatusPhase
|
||||
|
|
@ -3701,6 +3727,27 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
|
||||
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
|
||||
// Same-arc theater refresh / absorb still allowed.
|
||||
if (CombatFightIsHot(_current, now)
|
||||
&& !IsSameStoryArc(_current, candidate)
|
||||
&& candidate.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
if (IsCombatUrgentPeer(_current, candidate))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"combat_urgent_cut",
|
||||
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
InterestDropLog.Record(
|
||||
"combat_hold",
|
||||
$"cur={_current.Key} next={candidate.Key}");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool inGrace = InQuietGrace;
|
||||
if (inGrace)
|
||||
{
|
||||
|
|
@ -3771,33 +3818,17 @@ public static class InterestDirector
|
|||
return true;
|
||||
}
|
||||
|
||||
// Soft family pack / thin Duel: yield to discrete unit events after a short hold.
|
||||
// Soft family pack: yield to discrete unit events after a short hold.
|
||||
if (IsSoftStickyCluster(_current)
|
||||
&& !IsSoftStickyCluster(candidate)
|
||||
&& !nextFill
|
||||
&& !IsAmbientShot(candidate)
|
||||
&& onCurrent >= SoftYieldSecondsFor(_current)
|
||||
&& onCurrent >= SoftClusterYieldSeconds
|
||||
&& nextScore >= curScore - w.rotateSlack)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Soft Duel class gate: notice-tier Story peers cut after SoftCombatYieldSeconds.
|
||||
// Harness combat sessions seed EventStrength ~120 so score-slack yield alone never clears;
|
||||
// live anon Duels with VisualConfidence can sit in the same band.
|
||||
if (IsSoftStickyCombat(_current)
|
||||
&& IsVarietyValveCandidate(candidate)
|
||||
&& !nextFill
|
||||
&& !IsAmbientShot(candidate)
|
||||
&& onCurrent >= SoftCombatYieldSeconds
|
||||
&& (nextScore >= w.noticeScoreMin || candidate.EventStrength >= w.noticeScoreMin))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"soft_duel_yield",
|
||||
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
|
||||
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
|
||||
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
|
||||
|
|
@ -3869,63 +3900,66 @@ public static class InterestDirector
|
|||
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
|
||||
private const float SoftClusterYieldSeconds = 4f;
|
||||
|
||||
/// <summary>Thin Duel soft-yield - longer than pack so scraps still read, shorter than hard sticky.</summary>
|
||||
private const float SoftCombatYieldSeconds = 8f;
|
||||
|
||||
private static bool IsSoftStickyCluster(InterestCandidate c) =>
|
||||
c != null
|
||||
&& (c.Completion == InterestCompletionKind.FamilyPack || IsSoftStickyCombat(c));
|
||||
c != null && c.Completion == InterestCompletionKind.FamilyPack;
|
||||
|
||||
/// <summary>True while a CombatActive scene still has a live scrap (incl. hysteresis).</summary>
|
||||
private static bool CombatFightIsHot(InterestCandidate scene, float now)
|
||||
{
|
||||
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_harnessCombatForcedCold && scene == _current)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return InterestCompletion.IsActive(scene, _currentStartedAt, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pair scrap without two notables - soft sticky (normal cut-in, short MaxWatch, early valve).
|
||||
/// Hard sticky reserved for Skirmish+ (<c>live_battle</c>), larger rosters, or notable-pair Duels.
|
||||
/// Do not use ContestedFighters here: lopsided Mass tips can stamp sides as 1v1 while the roster is large.
|
||||
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
|
||||
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
|
||||
/// </summary>
|
||||
private static bool IsSoftStickyCombat(InterestCandidate c)
|
||||
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (c == null || c.Completion != InterestCompletionKind.CombatActive)
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensemble framing (Skirmish/Battle/Mass) always hard-holds.
|
||||
if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.NotableParticipantCount >= 2)
|
||||
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
int sideSum = Math.Max(0, c.CombatSideACount) + Math.Max(0, c.CombatSideBCount);
|
||||
int size = Math.Max(Math.Max(0, c.ParticipantCount), sideSum);
|
||||
int skirmishFloor = Math.Max(3, w.skirmishMinFighters);
|
||||
if (size >= skirmishFloor)
|
||||
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
|
||||
if (next.TotalScore >= current.TotalScore + stickyMargin)
|
||||
{
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int duelMax = Math.Max(1, w.duelMaxFighters);
|
||||
// size 0 during attack_target gaps: keep soft if we never escalated past a pair.
|
||||
if (size == 0)
|
||||
{
|
||||
return c.CombatPeakParticipants <= duelMax;
|
||||
}
|
||||
|
||||
return size <= duelMax;
|
||||
float epicMin = w.combatUrgentEventStrengthMin > 0f
|
||||
? w.combatUrgentEventStrengthMin
|
||||
: 95f;
|
||||
float urgentMargin = w.combatUrgentCutInMargin > 0f
|
||||
? w.combatUrgentCutInMargin
|
||||
: 20f;
|
||||
return next.EventStrength >= epicMin
|
||||
&& next.TotalScore >= current.TotalScore + urgentMargin;
|
||||
}
|
||||
|
||||
private static float SoftYieldSecondsFor(InterestCandidate c) =>
|
||||
IsSoftStickyCombat(c) ? SoftCombatYieldSeconds : SoftClusterYieldSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
|
||||
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
|
||||
/// </summary>
|
||||
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
|
||||
!_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent);
|
||||
!_varietyValveUsed
|
||||
&& VarietyValveTimeReady(scene, onCurrent)
|
||||
&& !CombatFightIsHot(scene, Time.unscaledTime);
|
||||
|
||||
private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent)
|
||||
{
|
||||
|
|
@ -3937,11 +3971,7 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float after = IsSoftStickyCombat(scene)
|
||||
? (w.stickyVarietyValveAfterSecondsPair > 0f
|
||||
? w.stickyVarietyValveAfterSecondsPair
|
||||
: 8f)
|
||||
: (w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f);
|
||||
float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f;
|
||||
if (onCurrent >= after)
|
||||
{
|
||||
return true;
|
||||
|
|
@ -4472,6 +4502,7 @@ public static class InterestDirector
|
|||
_currentStartedAt = now;
|
||||
_lastSwitchAt = now;
|
||||
_inactiveSince = -999f;
|
||||
_harnessCombatForcedCold = false;
|
||||
if (!InterestScoring.IsFillScore(next.TotalScore))
|
||||
{
|
||||
_lastInterestingAt = now;
|
||||
|
|
|
|||
|
|
@ -157,12 +157,29 @@ public static class InterestScoring
|
|||
}
|
||||
}
|
||||
|
||||
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
|
||||
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
|
||||
c.TotalScore = c.EventStrength + scaleBonus
|
||||
+ c.CharacterSignificance * charWeight
|
||||
+ c.VisualConfidence * w.visualMultiplier
|
||||
+ c.Novelty * w.noveltyMultiplier;
|
||||
c.ScoreDetail =
|
||||
+ c.Novelty * w.noveltyMultiplier
|
||||
- repeatPenalty
|
||||
+ frequencyAdjust;
|
||||
string detail =
|
||||
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
|
||||
if (repeatPenalty > 0.05f)
|
||||
{
|
||||
detail += $" rpt=-{repeatPenalty:0.#}";
|
||||
}
|
||||
|
||||
if (Mathf.Abs(frequencyAdjust) > 0.05f)
|
||||
{
|
||||
detail += " freq="
|
||||
+ (frequencyAdjust >= 0f ? "+" : "")
|
||||
+ frequencyAdjust.ToString("0.#");
|
||||
}
|
||||
|
||||
c.ScoreDetail = detail;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,16 @@ public class ScoringWeights
|
|||
public float cutInMargin = 35f;
|
||||
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
|
||||
public float stickyCutInMargin = 50f;
|
||||
/// <summary>
|
||||
/// EventStrength floor for mid-fight urgent cuts (catalog epic-world band, 95-100).
|
||||
/// Disasters / kingdom fall may interrupt a live scrap; lovers and daily Action may not.
|
||||
/// </summary>
|
||||
public float combatUrgentEventStrengthMin = 95f;
|
||||
/// <summary>
|
||||
/// Score margin over the live fight required when EventStrength clears
|
||||
/// <see cref="combatUrgentEventStrengthMin"/> (smaller than stickyCutInMargin).
|
||||
/// </summary>
|
||||
public float combatUrgentCutInMargin = 20f;
|
||||
/// <summary>After this many seconds on sticky combat/war, variety-valve peers may cut in.</summary>
|
||||
public float stickyVarietyValveAfterSeconds = 16f;
|
||||
/// <summary>
|
||||
|
|
@ -136,6 +146,25 @@ public class ScoringWeights
|
|||
/// on the same subject so life milestones do not reclaim the camera every few seconds.
|
||||
/// </summary>
|
||||
public float fixedDwellBeatCooldownSeconds = 45f;
|
||||
/// <summary>
|
||||
/// Score subtracted per consecutive camera show of the same variety arc
|
||||
/// (e.g. duel → duel). Resets when a different arc is shown.
|
||||
/// </summary>
|
||||
public float repeatArcPenaltyPer = 24f;
|
||||
/// <summary>Cap on stacked repeat-arc penalty.</summary>
|
||||
public float repeatArcPenaltyCap = 72f;
|
||||
/// <summary>Rolling tip window for rarity/commonality score adjust.</summary>
|
||||
public int arcFrequencyWindow = 16;
|
||||
/// <summary>Soft per-arc share target inside the window (common arcs above this lose score).</summary>
|
||||
public float arcFairShare = 0.12f;
|
||||
/// <summary>Max TotalScore penalty when an arc saturates the window.</summary>
|
||||
public float arcOverSharePenaltyMax = 48f;
|
||||
/// <summary>Max TotalScore boost when an arc is absent from the window (rare).</summary>
|
||||
public float arcUnderShareBoostMax = 22f;
|
||||
/// <summary>Soft share cap for all combat arcs combined (duel + multi).</summary>
|
||||
public float arcCombatShareTarget = 0.35f;
|
||||
/// <summary>Extra penalty when combat share exceeds <see cref="arcCombatShareTarget"/>.</summary>
|
||||
public float arcCombatOverSharePenaltyMax = 36f;
|
||||
public int enrichTopK = 8;
|
||||
public float metaCacheTtl = 2f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties
|
||||
/// and hard FixedDwell beat cooldowns (same life beat / subject cannot reclaim immediately).
|
||||
/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties,
|
||||
/// hard FixedDwell beat cooldowns, consecutive arc drop-off, and a rolling
|
||||
/// rarity window (common arcs lose score; rare/absent arcs gain score).
|
||||
/// </summary>
|
||||
public static class InterestVariety
|
||||
{
|
||||
|
|
@ -17,6 +18,7 @@ public static class InterestVariety
|
|||
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
||||
|
||||
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
|
||||
private static readonly List<string> RecentArcs = new List<string>(32);
|
||||
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
|
||||
private static readonly System.Random Rng = new System.Random();
|
||||
|
||||
|
|
@ -24,14 +26,44 @@ public static class InterestVariety
|
|||
private static readonly List<InterestCandidate> CharPool = new List<InterestCandidate>(64);
|
||||
private static readonly List<InterestCandidate> Ranked = new List<InterestCandidate>(16);
|
||||
|
||||
private static string _lastArcKey = "";
|
||||
private static int _arcStreak;
|
||||
|
||||
/// <summary>Whether the last <see cref="Pick"/> saw both pools non-empty (for mix metering).</summary>
|
||||
public static bool LastPickHadBothPools { get; private set; }
|
||||
|
||||
/// <summary>Harness/debug: last shown variety arc key.</summary>
|
||||
public static string LastArcKey => _lastArcKey ?? "";
|
||||
|
||||
/// <summary>Harness/debug: consecutive shows of <see cref="LastArcKey"/>.</summary>
|
||||
public static int ArcStreak => _arcStreak;
|
||||
|
||||
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
|
||||
public static int ArcWindowCount => RecentArcs.Count;
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
RecentMix.Clear();
|
||||
RecentArcs.Clear();
|
||||
CooldownUntil.Clear();
|
||||
LastPickHadBothPools = false;
|
||||
_lastArcKey = "";
|
||||
_arcStreak = 0;
|
||||
}
|
||||
|
||||
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary>
|
||||
public static void HarnessPushArc(string arcKey, int times = 1)
|
||||
{
|
||||
if (string.IsNullOrEmpty(arcKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int n = Mathf.Max(1, times);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
PushArc(arcKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary>
|
||||
|
|
@ -68,6 +100,8 @@ public static class InterestVariety
|
|||
return;
|
||||
}
|
||||
|
||||
NoteVarietyArc(chosen);
|
||||
|
||||
if (updateMix)
|
||||
{
|
||||
RecentMix.Add(chosen.LeadKind);
|
||||
|
|
@ -120,6 +154,267 @@ public static class InterestVariety
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score to subtract when this candidate matches the last shown variety arc.
|
||||
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
||||
/// </summary>
|
||||
public static float RepeatArcPenalty(InterestCandidate c)
|
||||
{
|
||||
if (c == null || _arcStreak <= 0 || string.IsNullOrEmpty(_lastArcKey))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
string arc = VarietyArcKey(c);
|
||||
if (string.IsNullOrEmpty(arc)
|
||||
|| !string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float per = w.repeatArcPenaltyPer > 0f ? w.repeatArcPenaltyPer : 24f;
|
||||
float cap = w.repeatArcPenaltyCap > 0f ? w.repeatArcPenaltyCap : 72f;
|
||||
return Mathf.Min(cap, _arcStreak * per);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolling rarity: arcs over-represented in the recent tip window lose score;
|
||||
/// absent/rare arcs gain score. Combat as a family gets an extra monopoly penalty.
|
||||
/// </summary>
|
||||
public static float FrequencyAdjust(InterestCandidate c)
|
||||
{
|
||||
if (c == null || RecentArcs.Count == 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
string arc = VarietyArcKey(c);
|
||||
if (string.IsNullOrEmpty(arc))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
int window = Mathf.Max(4, w.arcFrequencyWindow > 0 ? w.arcFrequencyWindow : 16);
|
||||
int sample = Mathf.Min(RecentArcs.Count, window);
|
||||
if (sample <= 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
int arcCount = 0;
|
||||
int combatCount = 0;
|
||||
int start = RecentArcs.Count - sample;
|
||||
for (int i = start; i < RecentArcs.Count; i++)
|
||||
{
|
||||
string a = RecentArcs[i];
|
||||
if (string.Equals(a, arc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
arcCount++;
|
||||
}
|
||||
|
||||
if (IsCombatArc(a))
|
||||
{
|
||||
combatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
float share = arcCount / (float)sample;
|
||||
float fair = w.arcFairShare > 0.02f ? w.arcFairShare : 0.12f;
|
||||
float overMax = w.arcOverSharePenaltyMax > 0f ? w.arcOverSharePenaltyMax : 48f;
|
||||
float underMax = w.arcUnderShareBoostMax > 0f ? w.arcUnderShareBoostMax : 22f;
|
||||
|
||||
float adjust = 0f;
|
||||
if (share > fair)
|
||||
{
|
||||
float t = Mathf.Clamp01((share - fair) / Mathf.Max(0.05f, 1f - fair));
|
||||
adjust -= t * overMax;
|
||||
}
|
||||
else
|
||||
{
|
||||
float t = Mathf.Clamp01((fair - share) / fair);
|
||||
adjust += t * underMax;
|
||||
}
|
||||
|
||||
if (IsCombatArc(arc))
|
||||
{
|
||||
float combatShare = combatCount / (float)sample;
|
||||
float combatTarget = w.arcCombatShareTarget > 0.05f ? w.arcCombatShareTarget : 0.35f;
|
||||
float combatPen = w.arcCombatOverSharePenaltyMax > 0f ? w.arcCombatOverSharePenaltyMax : 36f;
|
||||
if (combatShare > combatTarget)
|
||||
{
|
||||
float t = Mathf.Clamp01((combatShare - combatTarget) / Mathf.Max(0.05f, 1f - combatTarget));
|
||||
adjust -= t * combatPen;
|
||||
}
|
||||
}
|
||||
|
||||
return adjust;
|
||||
}
|
||||
|
||||
private static bool IsCombatArc(string arc) =>
|
||||
!string.IsNullOrEmpty(arc)
|
||||
&& (arc.StartsWith("arc:combat:", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(arc, "arc:war", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Class-level arc for repeat drop-off (not per unit / pair id).
|
||||
/// Duels share one arc so back-to-back scraps lose score; Mass/Battle share another.
|
||||
/// </summary>
|
||||
public static string VarietyArcKey(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (c.Completion == InterestCompletionKind.WarFront
|
||||
|| string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "arc:war";
|
||||
}
|
||||
|
||||
if (string.Equals(c.Category, "Forage", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.AssetId, "building_consume_fruit", StringComparison.OrdinalIgnoreCase)
|
||||
|| (!string.IsNullOrEmpty(c.Label)
|
||||
&& c.Label.IndexOf("foraging", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
return "arc:forage";
|
||||
}
|
||||
|
||||
if (c.Completion == InterestCompletionKind.PlotActive
|
||||
|| string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "arc:plot";
|
||||
}
|
||||
|
||||
if (c.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| string.Equals(c.AssetId, "live_outbreak", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "arc:outbreak";
|
||||
}
|
||||
|
||||
if (c.Completion == InterestCompletionKind.CombatActive
|
||||
|| string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.Verb, "fighting", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "arc:combat:multi";
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
int sideSum = Mathf.Max(0, c.CombatSideACount) + Mathf.Max(0, c.CombatSideBCount);
|
||||
int size = Mathf.Max(Mathf.Max(0, c.ParticipantCount), sideSum);
|
||||
int duelMax = Mathf.Max(1, w.duelMaxFighters);
|
||||
if (size > 0 && size <= duelMax)
|
||||
{
|
||||
return "arc:combat:duel";
|
||||
}
|
||||
|
||||
return "arc:combat:multi";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c.HappinessEffectId))
|
||||
{
|
||||
string h = c.HappinessEffectId.Trim().ToLowerInvariant();
|
||||
if (h.IndexOf("death_", StringComparison.Ordinal) >= 0
|
||||
|| h.IndexOf("mourn", StringComparison.Ordinal) >= 0
|
||||
|| h.IndexOf("despair", StringComparison.Ordinal) >= 0
|
||||
|| h.IndexOf("grief", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return "arc:life:grief";
|
||||
}
|
||||
|
||||
if (h.IndexOf("lover", StringComparison.Ordinal) >= 0
|
||||
|| h.IndexOf("love", StringComparison.Ordinal) >= 0
|
||||
|| h.IndexOf("married", StringComparison.Ordinal) >= 0
|
||||
|| h.IndexOf("wedding", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return "arc:life:love";
|
||||
}
|
||||
|
||||
return "arc:hap:" + h;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c.StatusId))
|
||||
{
|
||||
return "arc:status:" + c.StatusId.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.Ordinal))
|
||||
{
|
||||
if (string.Equals(c.Category, "Spectacle", StringComparison.OrdinalIgnoreCase)
|
||||
|| asset.StartsWith("cast_", StringComparison.Ordinal)
|
||||
|| asset.StartsWith("summon_", StringComparison.Ordinal))
|
||||
{
|
||||
return "arc:spectacle";
|
||||
}
|
||||
|
||||
if (string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase)
|
||||
|| (c.Label != null
|
||||
&& c.Label.IndexOf("decides to", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
return "arc:decision";
|
||||
}
|
||||
|
||||
return "arc:asset:" + asset;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c.Category))
|
||||
{
|
||||
return "arc:cat:" + c.Category.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c.Verb))
|
||||
{
|
||||
return "arc:verb:" + c.Verb.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static void NoteVarietyArc(InterestCandidate chosen)
|
||||
{
|
||||
string arc = VarietyArcKey(chosen);
|
||||
if (string.IsNullOrEmpty(arc))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_arcStreak = Mathf.Max(1, _arcStreak + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastArcKey = arc;
|
||||
_arcStreak = 1;
|
||||
}
|
||||
|
||||
PushArc(arc);
|
||||
}
|
||||
|
||||
private static void PushArc(string arc)
|
||||
{
|
||||
if (string.IsNullOrEmpty(arc))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RecentArcs.Add(arc);
|
||||
int window = Mathf.Max(4, InterestScoringConfig.W.arcFrequencyWindow > 0
|
||||
? InterestScoringConfig.W.arcFrequencyWindow
|
||||
: 16);
|
||||
while (RecentArcs.Count > window)
|
||||
{
|
||||
RecentArcs.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a FixedDwell / moment beat for this candidate is still hard-cooled.
|
||||
/// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.28.33",
|
||||
"version": "0.28.38",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"modVersion": "0.28.32",
|
||||
"modVersion": "0.28.38",
|
||||
"title": "IdleSpectator interest scoring model",
|
||||
"updated": "2026-07-17",
|
||||
"updated": "2026-07-18",
|
||||
"note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
|
||||
|
||||
"weights": {
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
|
||||
"cutInMargin": 35,
|
||||
"stickyCutInMargin": 50,
|
||||
"combatUrgentEventStrengthMin": 95,
|
||||
"combatUrgentCutInMargin": 20,
|
||||
"stickyVarietyValveAfterSeconds": 16,
|
||||
"stickyVarietyValveAfterSecondsPair": 8,
|
||||
"stickyVarietyValveMaxWatchFrac": 0.4,
|
||||
|
|
@ -98,6 +100,14 @@
|
|||
"mixWindow": 20,
|
||||
"hardCooldownSeconds": 12,
|
||||
"fixedDwellBeatCooldownSeconds": 45,
|
||||
"repeatArcPenaltyPer": 24,
|
||||
"repeatArcPenaltyCap": 72,
|
||||
"arcFrequencyWindow": 16,
|
||||
"arcFairShare": 0.12,
|
||||
"arcOverSharePenaltyMax": 48,
|
||||
"arcUnderShareBoostMax": 22,
|
||||
"arcCombatShareTarget": 0.35,
|
||||
"arcCombatOverSharePenaltyMax": 36,
|
||||
"enrichTopK": 8,
|
||||
"metaCacheTtl": 2
|
||||
},
|
||||
|
|
@ -115,10 +125,13 @@
|
|||
"Actions matter much more than characters.",
|
||||
"Same action intensity → more important character wins (tie-break).",
|
||||
"Multi-person fights outrank anonymous 1v1s.",
|
||||
"Anonymous / single-notable Duels are soft sticky (short MaxWatch, early variety valve).",
|
||||
"Hard sticky combat is reserved for Skirmish+ or notable-pair Duels.",
|
||||
"Live CombatActive holds the camera until the scrap goes cold (see who wins).",
|
||||
"Extremely urgent peers (EventStrength >= combatUrgentEventStrengthMin, or stickyCutInMargin) may cut mid-fight.",
|
||||
"Extremely notable 1v1s (kings/favorites) can beat nameless melees.",
|
||||
"Founding tips fire on meta outcomes (new_city / new_kingdom), not decision intent.",
|
||||
"Consecutive same variety-arc shows stack a score penalty until a different arc is shown.",
|
||||
"Rolling window: over-shown arcs lose score; absent/rare arcs gain score (rarer feels hotter).",
|
||||
"Combat share above arcCombatShareTarget gets an extra monopoly penalty.",
|
||||
"TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts."
|
||||
],
|
||||
"pipeline": [
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ Anonymous / single-notable Duels are soft sticky: MaxWatch 24, variety valve at
|
|||
|
||||
Founding tips come from `new_city` / `new_kingdom` meta outcomes, not decision intent.
|
||||
|
||||
Consecutive shows of the same variety arc (e.g. duel → duel) subtract `repeatArcPenaltyPer` from TotalScore (capped), resetting when a different arc is shown.
|
||||
|
||||
A rolling tip window also adjusts score by frequency: over-shown arcs lose up to `arcOverSharePenaltyMax`, rare/absent arcs gain up to `arcUnderShareBoostMax`, with an extra combat monopoly penalty above `arcCombatShareTarget`.
|
||||
|
||||
Fruit-bush foraging is fill-band strength so it does not crowd the camera.
|
||||
|
||||
Live combat holds the camera until the scrap goes cold (see who wins); variety peers cut after the fight ends.
|
||||
|
||||
Edit the `weights` block in scoring-model.json to change live scoring formula knobs.
|
||||
|
||||
Edit event-catalog.json to change what each event is worth and what it says.
|
||||
|
|
|
|||
Loading…
Reference in a new issue