Enhance event handling and introduce soft duel mechanics.
- Update event catalog to include new decision intents for civilization founding - Implement soft duel yield mechanics in InterestDirector for combat interactions - Adjust scoring weights for soft duels and variety valve timings - Refactor event reasoning to improve spell casting labels and decision handling - Increment version to 0.28.33 in mod.json
This commit is contained in:
parent
f10424c5df
commit
7d23f590db
16 changed files with 355 additions and 64 deletions
|
|
@ -3800,7 +3800,8 @@ public static class AgentHarness
|
|||
{
|
||||
DiscreteEventEntry spellEntry = EventCatalog.Spell.GetOrFallback(id);
|
||||
key = "spell:" + spellEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = spellEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
// Match live DeferredInterestFeed (EventReason.Library), not raw template {id}.
|
||||
label = EventReason.Library(unit, "spell", spellEntry.Id);
|
||||
strength = spellEntry.EventStrength;
|
||||
category = spellEntry.Category;
|
||||
source = "spell";
|
||||
|
|
|
|||
|
|
@ -243,6 +243,16 @@ public static class CameraAInventoryHarness
|
|||
return "deny_plot_intent";
|
||||
}
|
||||
|
||||
if (s.Contains("start_new_civilization")
|
||||
|| s.Contains("start_new_civ")
|
||||
|| s.Contains("build_civ_city")
|
||||
|| s.Contains("city_foundation")
|
||||
|| s == "claim_land"
|
||||
|| s.StartsWith("claim_land_", StringComparison.Ordinal))
|
||||
{
|
||||
return "deny_genesis_intent";
|
||||
}
|
||||
|
||||
if (s.Contains("idle")
|
||||
|| s.Contains("walking")
|
||||
|| s.Contains("check")
|
||||
|
|
|
|||
|
|
@ -100,14 +100,14 @@ public static partial class EventCatalog
|
|||
|
||||
string s = id.ToLowerInvariant();
|
||||
|
||||
// Intent-only: setDecisionCooldown fires before a Plot exists (and often never does).
|
||||
// Real plot tips come from PlotInterestFeed / live World.plots.
|
||||
if (s == "try_new_plot" || s.StartsWith("try_new_plot_", StringComparison.Ordinal))
|
||||
// Intent-only: setDecisionCooldown fires before the outcome exists.
|
||||
// Plots → PlotInterestFeed; city/civ founding → MetaEventPatches (new_city / new_kingdom).
|
||||
if (IsIntentOnlyGenesisDecision(s))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Daily AI / check ticks. Foundation checks stay eligible for Signal tokens.
|
||||
// Daily AI / check ticks.
|
||||
if (IsFluffDecision(s))
|
||||
{
|
||||
return false;
|
||||
|
|
@ -124,8 +124,6 @@ public static partial class EventCatalog
|
|||
|| s.Contains("declare")
|
||||
|| s.Contains("invade")
|
||||
|| s.Contains("siege")
|
||||
|| s.Contains("found")
|
||||
|| s.Contains("city_foundation")
|
||||
|| s.Contains("army")
|
||||
|| s.Contains("lover")
|
||||
|| s.Contains("plot")
|
||||
|
|
@ -140,9 +138,6 @@ public static partial class EventCatalog
|
|||
|| s.Contains("have_child")
|
||||
|| s.Contains("birth")
|
||||
|| s.Contains("reproduction")
|
||||
|| s.Contains("civilization")
|
||||
|| s.Contains("civ_city")
|
||||
|| s.Contains("claim_land")
|
||||
|| s.Contains("golden_brain")
|
||||
|| s.Contains("make_skeleton")
|
||||
|| s.Contains("soul_harvest")
|
||||
|
|
@ -157,9 +152,35 @@ public static partial class EventCatalog
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure AI ticks / daily fluff. Not story beats.
|
||||
/// <c>city_foundation</c> checks are excluded so kings can Signal founding intent.
|
||||
/// Decision cooldown is AI intent, not the world outcome.
|
||||
/// Real camera tips come from plot / meta genesis feeds after confirm.
|
||||
/// </summary>
|
||||
private static bool IsIntentOnlyGenesisDecision(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s == "try_new_plot" || s.StartsWith("try_new_plot_", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (s.Contains("start_new_civilization")
|
||||
|| s.Contains("start_new_civ")
|
||||
|| s.Contains("build_civ_city")
|
||||
|| s.Contains("city_foundation")
|
||||
|| s == "claim_land"
|
||||
|| s.StartsWith("claim_land_", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Pure AI ticks / daily fluff. Not story beats.</summary>
|
||||
private static bool IsFluffDecision(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
|
|
@ -167,11 +188,6 @@ public static partial class EventCatalog
|
|||
return true;
|
||||
}
|
||||
|
||||
if (s.Contains("city_foundation"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s.StartsWith("check_", StringComparison.Ordinal)
|
||||
|| s.Contains("_check_")
|
||||
|| s.EndsWith("_check", StringComparison.Ordinal))
|
||||
|
|
|
|||
|
|
@ -20,12 +20,27 @@ public sealed class DiscreteEventEntry
|
|||
|
||||
public string MakeLabel(Actor a, Actor b = null)
|
||||
{
|
||||
string template = LabelTemplate ?? "";
|
||||
if (template.IndexOf("casts {id}", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| template.IndexOf("casts {ID}", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
string an = EventFeedUtil.SafeName(a);
|
||||
string obj = EventReason.SpellCastObject(Id);
|
||||
return (string.IsNullOrEmpty(an) ? "Someone" : an) + " casts " + obj;
|
||||
}
|
||||
|
||||
return EventReason.Apply(LabelTemplate, a, b, Id);
|
||||
}
|
||||
|
||||
public string MakeLabel(string a, string b)
|
||||
{
|
||||
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
|
||||
if (template.IndexOf("casts {id}", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
string an = string.IsNullOrEmpty(a) ? "Someone" : a;
|
||||
return an + " casts " + EventReason.SpellCastObject(Id);
|
||||
}
|
||||
|
||||
string displayId = string.IsNullOrEmpty(Id) ? "" : EventReason.HumanizeId(Id);
|
||||
return template
|
||||
.Replace("{id}", displayId)
|
||||
|
|
|
|||
|
|
@ -914,7 +914,12 @@ public static class EventReason
|
|||
switch (k)
|
||||
{
|
||||
case "spell":
|
||||
return hasActor ? an + " casts " + id : id + " is cast";
|
||||
{
|
||||
// Locale / humanize often keep a leading "Cast"/"Summon"; strip so we never
|
||||
// emit "casts Cast Blood Rain" or "casts Summon Lightning".
|
||||
string spellObject = SpellCastObject(assetId, id);
|
||||
return hasActor ? an + " casts " + spellObject : spellObject + " is cast";
|
||||
}
|
||||
case "item":
|
||||
return hasActor
|
||||
? an + " " + LibraryAssetNames.ItemVerb(assetId) + " " + id
|
||||
|
|
@ -1056,6 +1061,60 @@ public static class EventReason
|
|||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mid-sentence spell object after "casts …" - never repeats Cast/Summon from the asset name.
|
||||
/// </summary>
|
||||
public static string SpellCastObject(string spellId, string displayFallback = "")
|
||||
{
|
||||
string raw = (spellId ?? "").Trim().ToLowerInvariant().Replace('-', '_');
|
||||
string core = raw;
|
||||
if (core.StartsWith("cast_", StringComparison.Ordinal))
|
||||
{
|
||||
core = core.Substring(5);
|
||||
}
|
||||
else if (core.StartsWith("summon_", StringComparison.Ordinal))
|
||||
{
|
||||
core = core.Substring(7);
|
||||
}
|
||||
|
||||
string fromDisplay = StripLeadingCastVerb(displayFallback);
|
||||
if (string.IsNullOrEmpty(fromDisplay) && !string.IsNullOrEmpty(spellId))
|
||||
{
|
||||
fromDisplay = StripLeadingCastVerb(LibraryAssetNames.DisplayName("spell", spellId));
|
||||
}
|
||||
|
||||
string phrase = !string.IsNullOrEmpty(fromDisplay)
|
||||
? fromDisplay
|
||||
: HumanizeId(core);
|
||||
if (string.IsNullOrEmpty(phrase))
|
||||
{
|
||||
phrase = "a spell";
|
||||
}
|
||||
|
||||
return phrase.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string StripLeadingCastVerb(string display)
|
||||
{
|
||||
if (string.IsNullOrEmpty(display))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string d = display.Trim();
|
||||
if (d.StartsWith("Cast ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return d.Substring(5).Trim();
|
||||
}
|
||||
|
||||
if (d.StartsWith("Summon ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return d.Substring(7).Trim();
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
private static string NameOrSomeone(Actor a)
|
||||
{
|
||||
string n = Name(a);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ public static class MetaEventPatches
|
|||
[HarmonyPostfix]
|
||||
public static void PostfixNewCity(City __result)
|
||||
{
|
||||
EmitNew("new_city", "Politics", 74f, __result, "city");
|
||||
// Outcome tip (decision intent like build_civ_city_here is Layer B).
|
||||
EmitNew("new_city", "Politics", 86f, __result, "city");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(KingdomManager), nameof(KingdomManager.makeNewCivKingdom))]
|
||||
|
|
@ -93,10 +94,11 @@ public static class MetaEventPatches
|
|||
return;
|
||||
}
|
||||
|
||||
// Outcome tip (try_to_start_new_civilization decision intent is Layer B).
|
||||
MetaInterestFeed.EmitMeta(
|
||||
"new_kingdom",
|
||||
"Politics",
|
||||
82f,
|
||||
92f,
|
||||
anchor,
|
||||
EventReason.MetaNew(anchor, "kingdom"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ internal static class HarnessScenarios
|
|||
case "sticky_variety_valve":
|
||||
case "variety_valve":
|
||||
return StickyVarietyValve();
|
||||
case "soft_duel_yield":
|
||||
case "duel_yield":
|
||||
return SoftDuelYield();
|
||||
case "war_front_sticky":
|
||||
case "war_sticky":
|
||||
return WarFrontSticky();
|
||||
|
|
@ -215,6 +218,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_stability", "combat_stability_live"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
|
|
@ -1335,7 +1339,7 @@ internal static class HarnessScenarios
|
|||
Step("sv22", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
Step("sv22b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||
Step("sv22c", "interest_expire_pending", value: ""),
|
||||
Step("sv23", "age_current", wait: 21f),
|
||||
Step("sv23", "age_current", wait: 17f),
|
||||
Step("sv24", "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("sv24b", "assert", expect: "pending_contains", value: "valve_lover"),
|
||||
|
|
@ -1349,6 +1353,53 @@ 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.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> SoftDuelYield()
|
||||
{
|
||||
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"),
|
||||
|
||||
// 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 -"),
|
||||
|
||||
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("sd90", "fast_timing", value: "false"),
|
||||
Step("sd99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
|
||||
/// </summary>
|
||||
|
|
@ -1447,6 +1498,10 @@ internal static class HarnessScenarios
|
|||
|
||||
// Intent-only AI decision must not own the camera - wait for a live Plot instead.
|
||||
Step("pcs60", "assert", expect: "decision_worthy", value: "try_new_plot", label: "false"),
|
||||
Step("pcs60b", "assert", expect: "decision_worthy", value: "try_to_start_new_civilization", label: "false"),
|
||||
Step("pcs60c", "assert", expect: "decision_worthy", value: "build_civ_city_here", label: "false"),
|
||||
Step("pcs60d", "assert", expect: "decision_worthy", value: "king_check_new_city_foundation", label: "false"),
|
||||
Step("pcs60e", "assert", expect: "decision_worthy", value: "claim_land", label: "false"),
|
||||
Step("pcs61", "assert", expect: "decision_worthy", value: "find_lover", label: "true"),
|
||||
|
||||
Step("pcs90", "fast_timing", value: "false"),
|
||||
|
|
@ -2050,7 +2105,11 @@ internal static class HarnessScenarios
|
|||
Step("et35", "assert", expect: "interest_has_key", value: "building:"),
|
||||
Step("et40", "domain_feed", asset: "spell", value: "summon_lightning"),
|
||||
Step("et41", "assert", expect: "interest_has_key", value: "spell:summon_lightning"),
|
||||
Step("et42", "domain_feed", asset: "item", value: "sword"),
|
||||
Step("et41b", "assert", expect: "interest_label_contains", value: "casts lightning"),
|
||||
Step("et42", "domain_feed", asset: "spell", value: "cast_blood_rain"),
|
||||
Step("et42b", "assert", expect: "interest_has_key", value: "spell:cast_blood_rain"),
|
||||
Step("et42c", "assert", expect: "interest_label_contains", value: "casts blood rain"),
|
||||
Step("et42d", "domain_feed", asset: "item", value: "sword"),
|
||||
Step("et43", "assert", expect: "interest_has_key", value: "item:"),
|
||||
Step("et44", "domain_feed", asset: "decision", value: "declare_war"),
|
||||
Step("et45", "assert", expect: "interest_has_key", value: "decision:"),
|
||||
|
|
|
|||
|
|
@ -3542,6 +3542,10 @@ public static class InterestDirector
|
|||
switch (current.Completion)
|
||||
{
|
||||
case InterestCompletionKind.CombatActive:
|
||||
classCap = IsSoftStickyCombat(current)
|
||||
? (w.maxWatchDuel > 0f ? w.maxWatchDuel : 24f)
|
||||
: w.maxWatchCombat;
|
||||
break;
|
||||
case InterestCompletionKind.WarFront:
|
||||
case InterestCompletionKind.PlotActive:
|
||||
case InterestCompletionKind.EarthquakeActive:
|
||||
|
|
@ -3576,9 +3580,15 @@ 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.
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
else if (current.Completion == InterestCompletionKind.CombatActive
|
||||
|| current.Completion == InterestCompletionKind.WarFront
|
||||
|| current.Completion == InterestCompletionKind.PlotActive
|
||||
|| current.Completion == InterestCompletionKind.StatusPhase
|
||||
|
|
@ -3705,18 +3715,19 @@ public static class InterestDirector
|
|||
float graceCur = _current.TotalScore;
|
||||
float graceNext = candidate.TotalScore;
|
||||
float onCurrentGrace = now - _currentStartedAt;
|
||||
float graceMargin = Mathf.Max(
|
||||
InterestScoringConfig.W.cutInMargin,
|
||||
InterestScoringConfig.W.stickyCutInMargin > 0f
|
||||
? InterestScoringConfig.W.stickyCutInMargin
|
||||
: 50f);
|
||||
ScoringWeights gw = InterestScoringConfig.W;
|
||||
float graceMargin = IsSoftStickyCluster(_current)
|
||||
? gw.cutInMargin
|
||||
: Mathf.Max(
|
||||
gw.cutInMargin,
|
||||
gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f);
|
||||
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
|
||||
{
|
||||
float valveMargin = InterestScoringConfig.W.stickyVarietyValveMargin > 0f
|
||||
? InterestScoringConfig.W.stickyVarietyValveMargin
|
||||
float valveMargin = gw.stickyVarietyValveMargin > 0f
|
||||
? gw.stickyVarietyValveMargin
|
||||
: 20f;
|
||||
graceMargin = Mathf.Min(graceMargin, valveMargin);
|
||||
if (graceNext >= graceCur - InterestScoringConfig.W.rotateSlack
|
||||
if (graceNext >= graceCur - gw.rotateSlack
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
||||
{
|
||||
return true;
|
||||
|
|
@ -3760,17 +3771,33 @@ public static class InterestDirector
|
|||
return true;
|
||||
}
|
||||
|
||||
// Soft family pack: yield to discrete unit events after a short hold.
|
||||
// Soft family pack / thin Duel: yield to discrete unit events after a short hold.
|
||||
if (IsSoftStickyCluster(_current)
|
||||
&& !IsSoftStickyCluster(candidate)
|
||||
&& !nextFill
|
||||
&& !IsAmbientShot(candidate)
|
||||
&& onCurrent >= SoftClusterYieldSeconds
|
||||
&& onCurrent >= SoftYieldSecondsFor(_current)
|
||||
&& 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))
|
||||
|
|
@ -3842,8 +3869,57 @@ 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;
|
||||
c != null
|
||||
&& (c.Completion == InterestCompletionKind.FamilyPack || IsSoftStickyCombat(c));
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static bool IsSoftStickyCombat(InterestCandidate c)
|
||||
{
|
||||
if (c == null || c.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -3861,14 +3937,18 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 20f;
|
||||
float after = IsSoftStickyCombat(scene)
|
||||
? (w.stickyVarietyValveAfterSecondsPair > 0f
|
||||
? w.stickyVarietyValveAfterSecondsPair
|
||||
: 8f)
|
||||
: (w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f);
|
||||
if (onCurrent >= after)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float maxW = MaxWatchFor(scene);
|
||||
float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.5f;
|
||||
float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.4f;
|
||||
return maxW > 0f && onCurrent >= maxW * frac;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,9 +31,14 @@ public class ScoringWeights
|
|||
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
|
||||
public float stickyCutInMargin = 50f;
|
||||
/// <summary>After this many seconds on sticky combat/war, variety-valve peers may cut in.</summary>
|
||||
public float stickyVarietyValveAfterSeconds = 20f;
|
||||
/// <summary>Also open the valve after this fraction of MaxWatch (0.5 → 30s of a 60s combat).</summary>
|
||||
public float stickyVarietyValveMaxWatchFrac = 0.5f;
|
||||
public float stickyVarietyValveAfterSeconds = 16f;
|
||||
/// <summary>
|
||||
/// Soft pair-combat valve open time (anonymous / single-notable Duels).
|
||||
/// Multi-fighter and notable-pair scraps still use stickyVarietyValveAfterSeconds.
|
||||
/// </summary>
|
||||
public float stickyVarietyValveAfterSecondsPair = 8f;
|
||||
/// <summary>Also open the valve after this fraction of MaxWatch (0.4 → 24s of a 60s combat).</summary>
|
||||
public float stickyVarietyValveMaxWatchFrac = 0.4f;
|
||||
/// <summary>Cut-in margin while the variety valve is open (between rotateSlack and stickyCutInMargin).</summary>
|
||||
public float stickyVarietyValveMargin = 20f;
|
||||
/// <summary>Lopsided fight scale multiplier floor (38v1 → near this; 1v1 balanced → 1).</summary>
|
||||
|
|
@ -58,6 +63,8 @@ public class ScoringWeights
|
|||
public float maxWatchStatus = 30f;
|
||||
public float maxWatchGrief = 45f;
|
||||
public float maxWatchCombat = 60f;
|
||||
/// <summary>Cap for soft pair Duels (anonymous / single-notable); multi scrap uses maxWatchCombat.</summary>
|
||||
public float maxWatchDuel = 24f;
|
||||
public float maxWatchEpic = 50f;
|
||||
|
||||
public int massFighterThreshold = 4;
|
||||
|
|
@ -69,7 +76,7 @@ public class ScoringWeights
|
|||
public int duelMaxFighters = 2;
|
||||
public float duelNotablePairBonus = 55f;
|
||||
public float duelSingleNotableBonus = 15f;
|
||||
public float duelAnonymousPenalty = -12f;
|
||||
public float duelAnonymousPenalty = -24f;
|
||||
public int skirmishMinFighters = 3;
|
||||
public float notableSkirmishPer = 12f;
|
||||
public float notableSkirmishCap = 35f;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.28.27",
|
||||
"version": "0.28.33",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"modVersion": "0.16.0",
|
||||
"modVersion": "0.28.32",
|
||||
"title": "IdleSpectator interest scoring model",
|
||||
"updated": "2026-07-15",
|
||||
"note": "Edit weights below — InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
|
||||
"updated": "2026-07-17",
|
||||
"note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
|
||||
|
||||
"weights": {
|
||||
"charWeightEventLed": 0.18,
|
||||
|
|
@ -12,8 +12,9 @@
|
|||
|
||||
"cutInMargin": 35,
|
||||
"stickyCutInMargin": 50,
|
||||
"stickyVarietyValveAfterSeconds": 20,
|
||||
"stickyVarietyValveMaxWatchFrac": 0.5,
|
||||
"stickyVarietyValveAfterSeconds": 16,
|
||||
"stickyVarietyValveAfterSecondsPair": 8,
|
||||
"stickyVarietyValveMaxWatchFrac": 0.4,
|
||||
"stickyVarietyValveMargin": 20,
|
||||
"fightBalanceLopsidedFloor": 0.28,
|
||||
"fightBalanceFullAt": 0.55,
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"maxWatchStatus": 30,
|
||||
"maxWatchGrief": 45,
|
||||
"maxWatchCombat": 60,
|
||||
"maxWatchDuel": 24,
|
||||
"maxWatchEpic": 50,
|
||||
|
||||
"massFighterThreshold": 4,
|
||||
|
|
@ -42,7 +44,7 @@
|
|||
"duelMaxFighters": 2,
|
||||
"duelNotablePairBonus": 55,
|
||||
"duelSingleNotableBonus": 15,
|
||||
"duelAnonymousPenalty": -12,
|
||||
"duelAnonymousPenalty": -24,
|
||||
"skirmishMinFighters": 3,
|
||||
"notableSkirmishPer": 12,
|
||||
"notableSkirmishCap": 35,
|
||||
|
|
@ -113,9 +115,11 @@
|
|||
"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.",
|
||||
"Extremely notable 1v1s (kings/favorites) can beat nameless melees.",
|
||||
"TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts.",
|
||||
"InterestTier was removed - WorldLog seeds EventStrength; director uses cutInMargin / rotateSlack / dwell bands."
|
||||
"Founding tips fire on meta outcomes (new_city / new_kingdom), not decision intent.",
|
||||
"TotalScore is the sole ranking signal; typed rules (combat vs vignette) gate interrupts."
|
||||
],
|
||||
"pipeline": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ Seeded via `LiveLibraryInterest.EnsureSeeded` + JSON `libraries.*` dials.
|
|||
|
||||
| Domain | Live | Camera A | Dial label template | Notes | Priority |
|
||||
|--------|-----:|---------:|---------------------|-------|----------|
|
||||
| decisions | 127 | **37** | `{a} decides to {action}` | Signal tokens + authored phrases; fluff/`try_new_plot` B; dump `.harness/camera-a-inventory.tsv` | P3 expanded 0.28.27 |
|
||||
| decisions | 127 | **33** | `{a} decides to {action}` | Signal tokens + authored phrases; fluff + intent-only genesis (`try_new_plot`, founding/claim) B; dump `.harness/camera-a-inventory.tsv` | P3 variety 0.28.32 |
|
||||
| spells | 12 | 6 | `{a} casts {id}` | Signal spectacle only; FX spells B | P3 |
|
||||
| powers | 339 | **41** | `God power: {id}` | ambient CI=false; widened spectacle + creature powers; UI `_edit` B | P3 expanded 0.28.27 |
|
||||
| items | 111 | **42** | `{a} gains {id}` | Signal mythril/adamantine + silver/steel/bone; leather/iron B | P3 expanded 0.28.27 |
|
||||
|
|
@ -76,16 +76,18 @@ Seeded via `LiveLibraryInterest.EnsureSeeded` + JSON `libraries.*` dials.
|
|||
| clan_traits | 29 | 13 | `{a} clan gains {id}` | dramatic heuristic | P3 |
|
||||
| language_traits | 26 | 2 | `{a} tongue gains {id}` | dramatic heuristic | P3 |
|
||||
|
||||
**Library+decision Layer A inventory (0.28.27):** live dump total **219 A / 924 B** across 13 domains (`camera_a_inventory_ok`).
|
||||
**Library+decision Layer A inventory (0.28.32):** live dump total **215 A / 928 B** across 13 domains (`camera_a_inventory_ok`). Decisions **33 A**.
|
||||
|
||||
### Decision camera classes (0.28.27)
|
||||
### Decision camera classes (0.28.32)
|
||||
|
||||
Allow-token Signal after fluff filter (not 127 ambient).
|
||||
Allow-token Signal after fluff + intent-genesis filter (not 127 ambient).
|
||||
Authored action phrases required for every A id (`domain_event_audit` decision_prose).
|
||||
|
||||
Classes: founding/territory (`civilization`, `civ_city`, `claim_land`, `city_foundation`), reproduction/pack (`reproduction`, `family_group`), army/war/politics (existing), spectacle/dark (`golden_brain`, `make_skeleton`, `soul_harvest`, `possessed`), civic soft (`put_out_fire`, `burn_tumors`, `fireworks`, `affect_dreams`), hive (`create_hive`).
|
||||
Classes: reproduction/pack (`reproduction`, `family_group`), army/war/politics (existing), spectacle/dark (`golden_brain`, `make_skeleton`, `soul_harvest`, `possessed`), civic soft (`put_out_fire`, `burn_tumors`, `fireworks`, `affect_dreams`), hive (`create_hive`).
|
||||
|
||||
Stay B: `try_new_plot*`, diet/sleep/random/check ticks, `warrior_train_with_dummy`, boat trade/fish, house/job/tax chores.
|
||||
Stay B (intent-only; camera on outcome feeds): `try_new_plot*`, `try_to_start_new_civilization*`, `build_civ_city*`, `*city_foundation*`, `claim_land*` → real tips from `PlotInterestFeed` / `MetaEventPatches` (`new_city` 86, `new_kingdom` 92).
|
||||
|
||||
Also stay B: diet/sleep/random/check ticks, `warrior_train_with_dummy`, boat trade/fish, house/job/tax chores.
|
||||
|
||||
Spell Signal: spectacle summons/curses/fire/teleport/meteor; FX silence/shield/cure/grass/vegetation/skeleton stay B.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ traits 116 authored 0 116 wired authored_labels P3
|
|||
disasters 17 authored 0 via_worldlog wired authored_labels P3 disaster_war_audit PASS
|
||||
war_types_library 5 authored 0 dials wired authored_labels P3
|
||||
relationship 11 authored_discrete 0 11 wired authored_labels P3 no AssetManager lib
|
||||
decisions_library 127 live+dial 0 37 wired 37_action_phrases P3 expanded 0.28.27; camera-a-inventory.tsv
|
||||
decisions_library 127 live+dial 0 33 wired action_phrases P3 intent genesis demoted 0.28.32; camera-a-inventory.tsv
|
||||
spells 12 live+dial 0 6 wired display_names P3 Signal spectacle; 6 FX B
|
||||
powers 339 live+dial 0 41 wired display_names P3 Signal widened 0.28.27; ambient CI=false
|
||||
items 111 live+dial 0 42 wired gains_verb P3 Signal mythril/adamantine/silver/steel/bone
|
||||
|
|
@ -24,6 +24,6 @@ clan_traits 29 live+dial 0 13 wired display_names P3
|
|||
language_traits 26 live+dial 0 2 wired display_names P3
|
||||
buildings 618 patch_only n/a consume_and_civ_complete partial EventReason.BuildingEat_BuildingComplete P3 civ complete/wonder A; destroy demoted B
|
||||
boats n/a patch_only n/a trade_unload wired EventReason.Boat P3 load skipped
|
||||
combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3
|
||||
camera_a_inventory 1143 harness 0 219 wired camera-a-inventory.tsv P3 A=219 B=924 domains=13; gate camera_a_inventory_ok
|
||||
combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3 soft Duel sticky 0.28.28; hard sticky Skirmish+/notable-pair
|
||||
camera_a_inventory 1143 harness 0 215 wired camera-a-inventory.tsv P3 A=215 B=928 domains=13; gate camera_a_inventory_ok
|
||||
mutation_gaps 0 harness 0 n/a gaps_empty n/a P3 managers=57 libraryAssets=2893
|
||||
|
|
|
|||
|
|
|
@ -4,6 +4,10 @@ decisions try_to_steal_money 68 Actor.setDecisionCooldown → DeferredInterestF
|
|||
decisions banish_unruly_clan_members 76 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
|
||||
decisions kill_unruly_clan_members 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
|
||||
decisions try_new_plot 78 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only (no Plot yet); camera via live PlotInterestFeed instead done
|
||||
decisions try_to_start_new_civilization 90 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only; camera via MetaEventPatches.new_kingdom (92) done
|
||||
decisions build_civ_city_here 86 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only; camera via MetaEventPatches.new_city (86) done
|
||||
decisions king_check_new_city_foundation 88 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent/check tick; camera via new_city outcome done
|
||||
decisions claim_land 70 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only genesis class done
|
||||
decisions king_change_kingdom_culture 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
|
||||
decisions king_change_kingdom_language 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
|
||||
decisions king_change_kingdom_religion 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
|
||||
|
|
|
|||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
**Per-event inventory (strength + prose):** [`IdleSpectator/event-catalog.json`](../IdleSpectator/event-catalog.json)
|
||||
|
||||
- `scoring-model.json` - margins, multipliers, rarity caps
|
||||
- `scoring-model.json` - margins, multipliers, rarity caps, soft-Duel / variety valve knobs
|
||||
- `event-catalog.json` - every authored event id (`happiness`, `status`, `worldLog`, `plots`, `decisions`, …)
|
||||
- Decisions use `action` (clause after "decides to …")
|
||||
- Most other domains use `label`
|
||||
|
|
@ -13,5 +13,16 @@
|
|||
|
||||
**Visual tracker (open beside chat):** [scoring-model canvas](/home/dazed/.cursor/projects/home-dazed-Projects-worldbox-mods-idle-mode/canvases/scoring-model.canvas.tsx)
|
||||
|
||||
## Variety policy (0.28.28)
|
||||
|
||||
`TotalScore = EventStrength + scaleBonus + char*w + visual*w + novelty*w`.
|
||||
|
||||
Hard sticky combat (margin 50, MaxWatch 60) is only for Skirmish+ or notable-pair Duels.
|
||||
|
||||
Anonymous / single-notable Duels are soft sticky: MaxWatch 24, variety valve at 8s, yield to Story peers after 8s.
|
||||
|
||||
Founding tips come from `new_city` / `new_kingdom` meta outcomes, not decision intent.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -124,9 +124,18 @@ for s in states:
|
|||
|
||||
def bucket(t: str) -> str:
|
||||
tl = t.lower()
|
||||
if any(x in tl for x in ("fight", "battle", "in a fight")):
|
||||
return "combat"
|
||||
if any(x in tl for x in ("war", "kingdom", "rebellion")):
|
||||
# Combat scale first (before generic fight / vs).
|
||||
if tl.startswith("duel") or " duel " in f" {tl} " or tl.startswith("duel -"):
|
||||
return "combat_duel"
|
||||
if any(tl.startswith(p) for p in ("skirmish", "battle", "mass")) or " mass -" in f" {tl} ":
|
||||
return "combat_multi"
|
||||
if any(x in tl for x in ("fighting", "in a fight", " vs ")) and "war -" not in tl and "plot -" not in tl:
|
||||
return "combat_other"
|
||||
if "decides to start a new civilization" in tl or "decides to found" in tl:
|
||||
return "decision_genesis_intent"
|
||||
if any(x in tl for x in ("new city", "new kingdom", "founds a", "founded")):
|
||||
return "meta_genesis"
|
||||
if any(x in tl for x in ("war -", "kingdom", "rebellion")):
|
||||
return "politics"
|
||||
if any(x in tl for x in ("love", "smitten", "child", "parenthood", "mourns", "lover")):
|
||||
return "relationship"
|
||||
|
|
@ -134,8 +143,8 @@ def bucket(t: str) -> str:
|
|||
return "status"
|
||||
if "new species" in tl or "appear" in tl or "hatch" in tl:
|
||||
return "discovery"
|
||||
if "plot" in tl or "decide" in tl:
|
||||
return "plot"
|
||||
if "plot" in tl or "decides to" in tl:
|
||||
return "plot_or_decision"
|
||||
if "fall" in tl and any(x in tl for x in ("tree", "plant", "bush", "flower", "maple")):
|
||||
return "object_fall"
|
||||
return "other"
|
||||
|
|
@ -215,6 +224,17 @@ if sleep_lag:
|
|||
print(f" tip={tip!r} caption={cap!r}")
|
||||
print()
|
||||
|
||||
genesis_intent = buckets.get("decision_genesis_intent", 0)
|
||||
duel_n = buckets.get("combat_duel", 0)
|
||||
multi_n = buckets.get("combat_multi", 0)
|
||||
combat_total = duel_n + multi_n + buckets.get("combat_other", 0)
|
||||
print(f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} genesis_intent={genesis_intent}")
|
||||
if genesis_intent:
|
||||
print(" NOTE: decision_genesis_intent tips should be 0 after 0.28.28 (meta outcomes only).")
|
||||
if combat_total >= 4 and multi_n == 0 and duel_n >= 3:
|
||||
print(" NOTE: combat tips are Duel-heavy; multi scrap may be scarce in this world.")
|
||||
print()
|
||||
|
||||
ok = (
|
||||
no_focus_bads == 0
|
||||
and focus_false == 0
|
||||
|
|
@ -223,6 +243,7 @@ ok = (
|
|||
and len(veg) == 0
|
||||
and len(mismatches) == 0
|
||||
and len(sleep_lag) == 0
|
||||
and genesis_intent == 0
|
||||
)
|
||||
print("VERDICT:", "PASS" if ok else "REVIEW")
|
||||
sys.exit(0 if ok else 1)
|
||||
|
|
|
|||
Loading…
Reference in a new issue