diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 4b08c58..303dfab 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -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";
diff --git a/IdleSpectator/CameraAInventoryHarness.cs b/IdleSpectator/CameraAInventoryHarness.cs
index d929bba..4bbee13 100644
--- a/IdleSpectator/CameraAInventoryHarness.cs
+++ b/IdleSpectator/CameraAInventoryHarness.cs
@@ -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")
diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs
index ab5f7d3..143ff63 100644
--- a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs
+++ b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs
@@ -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
}
///
- /// Pure AI ticks / daily fluff. Not story beats.
- /// city_foundation 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.
///
+ 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;
+ }
+
+ /// Pure AI ticks / daily fluff. Not story beats.
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))
diff --git a/IdleSpectator/Events/DiscreteEventEntry.cs b/IdleSpectator/Events/DiscreteEventEntry.cs
index 2ccc01e..17959c6 100644
--- a/IdleSpectator/Events/DiscreteEventEntry.cs
+++ b/IdleSpectator/Events/DiscreteEventEntry.cs
@@ -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)
diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs
index 2ded309..c3bfc78 100644
--- a/IdleSpectator/Events/EventReason.cs
+++ b/IdleSpectator/Events/EventReason.cs
@@ -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();
}
+ ///
+ /// Mid-sentence spell object after "casts …" - never repeats Cast/Summon from the asset name.
+ ///
+ 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);
diff --git a/IdleSpectator/Events/Patches/MetaEventPatches.cs b/IdleSpectator/Events/Patches/MetaEventPatches.cs
index 50c1265..183b885 100644
--- a/IdleSpectator/Events/Patches/MetaEventPatches.cs
+++ b/IdleSpectator/Events/Patches/MetaEventPatches.cs
@@ -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"));
}
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 3a5d695..35c380f 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -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
};
}
+ ///
+ /// Anonymous pair Duels are soft sticky: after a short hold, a Story/lover peer may cut in
+ /// without needing Mass-tier sticky margin.
+ ///
+ private static List SoftDuelYield()
+ {
+ return new List
+ {
+ 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"),
+ };
+ }
+
///
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
///
@@ -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:"),
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 6c04375..641e77a 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -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
/// Soft multi-actor holds that must yield to discrete unit events.
private const float SoftClusterYieldSeconds = 4f;
+ /// Thin Duel soft-yield - longer than pack so scraps still read, shorter than hard sticky.
+ 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));
+
+ ///
+ /// Pair scrap without two notables - soft sticky (normal cut-in, short MaxWatch, early valve).
+ /// Hard sticky reserved for Skirmish+ (live_battle), larger rosters, or notable-pair Duels.
+ /// Do not use ContestedFighters here: lopsided Mass tips can stamp sides as 1v1 while the roster is large.
+ ///
+ 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;
///
/// 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;
}
diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs
index de12876..65b1547 100644
--- a/IdleSpectator/InterestScoringConfig.cs
+++ b/IdleSpectator/InterestScoringConfig.cs
@@ -31,9 +31,14 @@ public class ScoringWeights
/// Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).
public float stickyCutInMargin = 50f;
/// After this many seconds on sticky combat/war, variety-valve peers may cut in.
- public float stickyVarietyValveAfterSeconds = 20f;
- /// Also open the valve after this fraction of MaxWatch (0.5 → 30s of a 60s combat).
- public float stickyVarietyValveMaxWatchFrac = 0.5f;
+ public float stickyVarietyValveAfterSeconds = 16f;
+ ///
+ /// Soft pair-combat valve open time (anonymous / single-notable Duels).
+ /// Multi-fighter and notable-pair scraps still use stickyVarietyValveAfterSeconds.
+ ///
+ public float stickyVarietyValveAfterSecondsPair = 8f;
+ /// Also open the valve after this fraction of MaxWatch (0.4 → 24s of a 60s combat).
+ public float stickyVarietyValveMaxWatchFrac = 0.4f;
/// Cut-in margin while the variety valve is open (between rotateSlack and stickyCutInMargin).
public float stickyVarietyValveMargin = 20f;
/// Lopsided fight scale multiplier floor (38v1 → near this; 1v1 balanced → 1).
@@ -58,6 +63,8 @@ public class ScoringWeights
public float maxWatchStatus = 30f;
public float maxWatchGrief = 45f;
public float maxWatchCombat = 60f;
+ /// Cap for soft pair Duels (anonymous / single-notable); multi scrap uses maxWatchCombat.
+ 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;
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 034e654..c313ed5 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -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"
}
diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json
index 7532e7d..844f601 100644
--- a/IdleSpectator/scoring-model.json
+++ b/IdleSpectator/scoring-model.json
@@ -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": [
{
diff --git a/docs/event-catalog-discovery.md b/docs/event-catalog-discovery.md
index 4906b60..73e83a2 100644
--- a/docs/event-catalog-discovery.md
+++ b/docs/event-catalog-discovery.md
@@ -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.
diff --git a/docs/event-catalog-discovery.tsv b/docs/event-catalog-discovery.tsv
index f15f9ee..128c221 100644
--- a/docs/event-catalog-discovery.tsv
+++ b/docs/event-catalog-discovery.tsv
@@ -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
diff --git a/docs/event-trigger-audit.tsv b/docs/event-trigger-audit.tsv
index 56437d5..8447afa 100644
--- a/docs/event-trigger-audit.tsv
+++ b/docs/event-trigger-audit.tsv
@@ -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
diff --git a/docs/scoring-model.md b/docs/scoring-model.md
index 2b2375e..92df30d 100644
--- a/docs/scoring-model.md
+++ b/docs/scoring-model.md
@@ -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.
diff --git a/scripts/soak-audit-player-log.sh b/scripts/soak-audit-player-log.sh
index ce28cd8..c18053f 100755
--- a/scripts/soak-audit-player-log.sh
+++ b/scripts/soak-audit-player-log.sh
@@ -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)