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)