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:
DazedAnon 2026-07-17 18:58:45 -05:00
parent f10424c5df
commit 7d23f590db
16 changed files with 355 additions and 64 deletions

View file

@ -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";

View file

@ -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")

View file

@ -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))

View file

@ -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)

View file

@ -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);

View file

@ -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"));
}

View file

@ -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:"),

View file

@ -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;
}

View file

@ -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;

View file

@ -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"
}

View file

@ -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": [
{

View file

@ -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.

View file

@ -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

1 library_or_hook live_count catalog_mode missing_ids camera_a wire_status tip_quality priority notes
9 disasters 17 authored 0 via_worldlog wired authored_labels P3 disaster_war_audit PASS
10 war_types_library 5 authored 0 dials wired authored_labels P3
11 relationship 11 authored_discrete 0 11 wired authored_labels P3 no AssetManager lib
12 decisions_library 127 live+dial 0 37 33 wired 37_action_phrases action_phrases P3 expanded 0.28.27; camera-a-inventory.tsv intent genesis demoted 0.28.32; camera-a-inventory.tsv
13 spells 12 live+dial 0 6 wired display_names P3 Signal spectacle; 6 FX B
14 powers 339 live+dial 0 41 wired display_names P3 Signal widened 0.28.27; ambient CI=false
15 items 111 live+dial 0 42 wired gains_verb P3 Signal mythril/adamantine/silver/steel/bone
24 language_traits 26 live+dial 0 2 wired display_names P3
25 buildings 618 patch_only n/a consume_and_civ_complete partial EventReason.BuildingEat_BuildingComplete P3 civ complete/wonder A; destroy demoted B
26 boats n/a patch_only n/a trade_unload wired EventReason.Boat P3 load skipped
27 combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3 soft Duel sticky 0.28.28; hard sticky Skirmish+/notable-pair
28 camera_a_inventory 1143 harness 0 219 215 wired camera-a-inventory.tsv P3 A=219 B=924 domains=13; gate camera_a_inventory_ok A=215 B=928 domains=13; gate camera_a_inventory_ok
29 mutation_gaps 0 harness 0 n/a gaps_empty n/a P3 managers=57 libraryAssets=2893

View file

@ -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

1 domain id strength camera game_call_site verdict fix_action done
4 decisions banish_unruly_clan_members 76 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
5 decisions kill_unruly_clan_members 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
6 decisions try_new_plot 78 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only (no Plot yet); camera via live PlotInterestFeed instead done
7 decisions try_to_start_new_civilization 90 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only; camera via MetaEventPatches.new_kingdom (92) done
8 decisions build_civ_city_here 86 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only; camera via MetaEventPatches.new_city (86) done
9 decisions king_check_new_city_foundation 88 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent/check tick; camera via new_city outcome done
10 decisions claim_land 70 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision demoted Intent-only genesis class done
11 decisions king_change_kingdom_culture 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
12 decisions king_change_kingdom_language 82 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done
13 decisions king_change_kingdom_religion 84 Actor.setDecisionCooldown → DeferredInterestFeed.EmitDecision accurate Camera via IsInterestingDecision predicate; action=infinitive done

View file

@ -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.

View file

@ -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)