From ff824c76a124ec5517708952a83ac605494cdb3d Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 00:27:37 -0500 Subject: [PATCH] Enhance interest management in IdleSpectator by refining event handling and scoring logic. Update various event catalogs to adjust event strengths and ownership attributes, ensuring more accurate representation of combat and relationship events. Revise InterestDirector and InterestCompletion to improve camera dwell management and combat activity detection. Increment version in mod.json to 0.20.12 to reflect these changes. --- IdleSpectator/DeferredLibraryCatalogs.cs | 3 +- IdleSpectator/InterestCompletion.cs | 62 +++++++-- IdleSpectator/InterestDirector.cs | 155 +++++++++++++++++++--- IdleSpectator/InterestFeeds.cs | 15 ++- IdleSpectator/InterestScoringConfig.cs | 12 +- IdleSpectator/RelationshipEventCatalog.cs | 13 +- IdleSpectator/StatusInterestCatalog.cs | 4 +- IdleSpectator/WorldActivityScanner.cs | 8 +- IdleSpectator/mod.json | 2 +- IdleSpectator/scoring-model.json | 11 +- docs/event-audit.md | 13 +- 11 files changed, 239 insertions(+), 59 deletions(-) diff --git a/IdleSpectator/DeferredLibraryCatalogs.cs b/IdleSpectator/DeferredLibraryCatalogs.cs index 2ed7c32..1047464 100644 --- a/IdleSpectator/DeferredLibraryCatalogs.cs +++ b/IdleSpectator/DeferredLibraryCatalogs.cs @@ -327,7 +327,8 @@ public static class PhenotypeInterestCatalog "{a} shows {id}", ambientStrength: 28f, signalIds: null, - signalStrength: 60f); + signalStrength: 60f, + ambientCreatesInterest: false); public static IEnumerable AuthoredIds { diff --git a/IdleSpectator/InterestCompletion.cs b/IdleSpectator/InterestCompletion.cs index cd2bd23..5f3ef0a 100644 --- a/IdleSpectator/InterestCompletion.cs +++ b/IdleSpectator/InterestCompletion.cs @@ -34,7 +34,8 @@ public static class InterestCompletion return age < candidate.MaxWatch; case InterestCompletionKind.FixedDwell: - return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch * 0.35f); + // Hold the authored MaxWatch window (director floors this to minCameraDwell). + return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch); case InterestCompletionKind.CombatActive: return CombatStillActive(candidate); @@ -64,17 +65,22 @@ public static class InterestCompletion return false; } + float now = Time.unscaledTime; + bool hot = false; try { if (unit.has_attack_target) { - return true; + hot = true; } - - if (unit.hasTask() && unit.ai?.task != null - && (unit.ai.task.in_combat || unit.ai.task.is_fireman)) + else if (unit.hasTask() && unit.ai?.task != null + && (unit.ai.task.in_combat || unit.ai.task.is_fireman)) { - return true; + hot = true; + } + else if (RelatedStillFightingUs(c, unit)) + { + hot = true; } } catch @@ -82,14 +88,50 @@ public static class InterestCompletion // ignore } - // Battle asset without a live fighter: keep briefly via position only if battle still hot. - if (c.AssetId == "live_battle") + if (!hot && c.AssetId == "live_battle") { InterestEvent battle = WorldActivityScanner.FindHottestBattle(); - return battle != null; + hot = battle != null; } - return false; + if (hot) + { + // Refresh so brief attack_target gaps do not clear reason / open quiet grace. + c.LastSeenAt = now; + return true; + } + + // Hysteresis: WorldBox clears attack_target between swings. + const float combatHysteresisSeconds = 8f; + return now - c.LastSeenAt < combatHysteresisSeconds; + } + + private static bool RelatedStillFightingUs(InterestCandidate c, Actor unit) + { + Actor foe = c.RelatedUnit; + if (foe == null || !foe.isAlive()) + { + return false; + } + + try + { + if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive()) + { + return false; + } + + if (!foe.attack_target.isActor()) + { + return false; + } + + return foe.attack_target.a == unit; + } + catch + { + return false; + } } private static bool ActivityStillActive(InterestCandidate c) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 67ae9c7..9df27dd 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -12,7 +12,7 @@ namespace IdleSpectator; /// public static class InterestDirector { - public const float MinDwellSeconds = 10f; + public const float MinDwellSeconds = 15f; public const float CuriosityDwellSeconds = 6f; public const float CuriosityRotateSeconds = 10f; public const float SwitchCooldownSeconds = 5f; @@ -94,11 +94,12 @@ public static class InterestDirector /// /// Candidate that may drive the orange dossier reason: owns - /// and the director session is still in its active dwell (not quiet_grace). + /// while the director still holds that scene (not quiet_grace). + /// Sticky combat may briefly report !IsActive between swings - keep the reason up. /// public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit) { - if (unit == null || _current == null || !CurrentIsActive || InQuietGrace) + if (unit == null || _current == null || InQuietGrace) { return null; } @@ -234,7 +235,8 @@ public static class InterestDirector } float now = Time.unscaledTime; - float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch * 0.35f) + 0.5f; + // Age past FixedDwell / Manual MaxWatch so IsActive is false. + float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 0.5f; _currentStartedAt = now - pastActive; _lastSwitchAt = _currentStartedAt; _inactiveSince = now - Mathf.Max(0f, inactiveSeconds); @@ -411,6 +413,29 @@ public static class InterestDirector return; } + // Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell. + if (IsAmbientShot(_current)) + { + if (_inactiveSince < 0f) + { + _inactiveSince = now; + } + + if (now - _inactiveSince >= _quietGrace) + { + EndCurrent(now, reason: "quiet_grace"); + } + + return; + } + + // Condition ended early: still hold the camera until min dwell, then grace. + if (onCurrent < MinDwellFor(_current)) + { + _inactiveSince = -999f; + return; + } + if (_inactiveSince < 0f) { _inactiveSince = now; @@ -649,29 +674,64 @@ public static class InterestDirector private static float MinDwellFor(InterestCandidate current) { + ScoringWeights w = InterestScoringConfig.W; + float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds; if (current == null) { - return _minDwell; + return _minDwell < MinDwellSeconds * 0.5f ? _minDwell : floor; + } + + // Ambient / Character fill: no event floor - real events may take the camera anytime. + if (IsAmbientShot(current)) + { + if (_minDwell < MinDwellSeconds * 0.5f) + { + return InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell; + } + + return w.dwellFill > 0f ? w.dwellFill : 6f; } - ScoringWeights w = InterestScoringConfig.W; float dwell = InterestScoring.IsHotScore(current.TotalScore) ? w.dwellHot - : (InterestScoring.IsFillScore(current.TotalScore) ? w.dwellFill : w.dwellNormal); + : w.dwellNormal; if (_minDwell < MinDwellSeconds * 0.5f) { // Harness fast timing: keep compressed dwells. - dwell = InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell; + dwell = _minDwell; + return current.MinWatch > 0f ? Mathf.Max(current.MinWatch, dwell) : dwell; } - if (current.MinWatch > 0f) + // Live EventLed: never peer-rotate / soft-cut before the camera floor. + // Score-margin interrupts bypass this via CanSwitchTo. + float minWatch = current.MinWatch > 0f ? current.MinWatch : 0f; + return Mathf.Max(floor, dwell, minWatch); + } + + /// Character fill / low-score stroll - events should cut in freely. + private static bool IsAmbientShot(InterestCandidate c) + { + if (c == null) { - return InterestScoring.IsFillScore(current.TotalScore) - ? Mathf.Min(current.MinWatch, dwell) - : Mathf.Max(current.MinWatch, dwell * 0.15f); + return true; } - return dwell; + if (c.LeadKind == InterestLeadKind.CharacterLed + || c.Completion == InterestCompletionKind.CharacterVignette) + { + return true; + } + + // Sticky event holds are never ambient even if score is temporarily fill-band. + if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.StatusPhase + || c.Completion == InterestCompletionKind.HappinessGrief + || c.Completion == InterestCompletionKind.ActivityActive) + { + return false; + } + + return InterestScoring.IsFillScore(c.TotalScore); } private static float MaxWatchFor(InterestCandidate current) @@ -709,7 +769,44 @@ public static class InterestDirector break; } - float cap = current.MaxWatch > 0f ? Mathf.Min(current.MaxWatch, classCap) : classCap; + float cap = classCap; + if (current.MaxWatch > 0f) + { + // 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 + || current.Completion == InterestCompletionKind.StatusPhase + || current.Completion == InterestCompletionKind.HappinessGrief + || current.Completion == InterestCompletionKind.ActivityActive) + { + cap = Mathf.Max(current.MaxWatch, classCap); + } + else + { + cap = Mathf.Min(current.MaxWatch, classCap); + } + } + + // Moment / FixedDwell shots must last at least the camera floor unless interrupted. + if (current.Completion == InterestCompletionKind.FixedDwell + || current.Completion == InterestCompletionKind.Manual) + { + float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds; + cap = Mathf.Max(cap, floor); + } + + // Ambient fill: no minCameraDwell floor - short vignettes are fine. + if (IsAmbientShot(current)) + { + float fillCap = w.dwellFill > 0f ? w.dwellFill * 2f : 20f; + if (current.MaxWatch > 0f) + { + fillCap = Mathf.Min(fillCap, Mathf.Max(current.MaxWatch, 8f)); + } + + cap = fillCap; + } + // Harness fast timing compresses caps. if (_minDwell < MinDwellSeconds * 0.5f) { @@ -735,6 +832,12 @@ public static class InterestDirector bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now); if (!active) { + // Ambient: no minCameraDwell hold after the vignette goes cold. + if (!IsAmbientShot(_current) && onCurrent < MinDwellFor(_current)) + { + return true; + } + if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace) { return false; @@ -763,6 +866,19 @@ public static class InterestDirector bool inGrace = InQuietGrace; if (inGrace) { + // Quiet grace after a sticky hold: only margin-worthy events may cut in. + // Prevents family-join / love status from stealing a fight between swings. + if (_current != null + && (_current.Completion == InterestCompletionKind.CombatActive + || _current.Completion == InterestCompletionKind.StatusPhase + || _current.Completion == InterestCompletionKind.HappinessGrief)) + { + float graceCur = _current.TotalScore; + float graceNext = candidate.TotalScore; + return graceNext >= graceCur + InterestScoringConfig.W.cutInMargin + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); + } + return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true); } @@ -776,7 +892,14 @@ public static class InterestDirector float curScore = _current.TotalScore; float nextScore = candidate.TotalScore; bool nextFill = InterestScoring.IsFillScore(nextScore); - bool curFill = InterestScoring.IsFillScore(curScore); + bool curAmbient = IsAmbientShot(_current); + bool curFill = curAmbient || InterestScoring.IsFillScore(curScore); + + // Ambient fill: any real event may take the camera immediately (no min dwell). + if (curAmbient && !nextFill && !IsAmbientShot(candidate)) + { + return true; + } // Instant score-margin cut - no settle grace, no MinDwell block. if (nextScore >= curScore + w.cutInMargin) @@ -797,7 +920,7 @@ public static class InterestDirector } // Protected EventLed hold: only margin cut-in (handled above). - if (protectedScene && !curFill) + if (protectedScene && !curAmbient) { return false; } diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs index 4d4e649..d0c4809 100644 --- a/IdleSpectator/InterestFeeds.cs +++ b/IdleSpectator/InterestFeeds.cs @@ -325,14 +325,15 @@ public static class InterestFeeds } string verb = taskOrBeat ?? "fighting"; - string key = "activity:" + id + ":" + verb; + // One combat key per fighter so activity + scanner merge instead of thrashing. + string key = "combat:" + id; var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = "Combat", Source = "activity", - EventStrength = 75f, + EventStrength = 88f, VisualConfidence = 0.8f, Position = actor.current_position, FollowUnit = actor, @@ -348,8 +349,8 @@ public static class InterestFeeds CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 20f, - MinWatch = 3f, - MaxWatch = 30f, + MinWatch = 4f, + MaxWatch = 60f, Completion = InterestCompletionKind.CombatActive }; candidate.ParticipantIds.Add(id); @@ -540,10 +541,10 @@ public static class InterestFeeds float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId); bool birthMoment = IsFreshLifeMoment(occ.EffectId); - // Birth/hatch only matter while still fresh - do not queue behind a long drama reel. + // Birth/hatch: short queue TTL, but on-camera floor comes from minCameraDwell. float ttl = birthMoment ? 7f : 35f; - float minWatch = birthMoment ? 2f : 4f; - float maxWatch = birthMoment ? 5.5f : 28f; + float minWatch = birthMoment ? 4f : 8f; + float maxWatch = birthMoment ? 15f : 28f; if (birthMoment) { strength = Mathf.Max(strength, 88f); diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index a65ea9c..7eed888 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -32,13 +32,15 @@ public class ScoringWeights public float fillScoreMax = 55f; public float noticeScoreMin = 70f; public float hotScoreMin = 110f; - public float dwellFill = 6f; - public float dwellNormal = 10f; - public float dwellHot = 14f; - public float fillRotateSeconds = 10f; + public float dwellFill = 8f; + public float dwellNormal = 15f; + public float dwellHot = 15f; + public float fillRotateSeconds = 8f; + /// Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt. + public float minCameraDwell = 15f; // MaxWatch caps by completion class (director clamps candidate.MaxWatch). - public float maxWatchMoment = 10f; + public float maxWatchMoment = 15f; public float maxWatchStatus = 30f; public float maxWatchGrief = 45f; public float maxWatchCombat = 60f; diff --git a/IdleSpectator/RelationshipEventCatalog.cs b/IdleSpectator/RelationshipEventCatalog.cs index 9a028d4..a384e78 100644 --- a/IdleSpectator/RelationshipEventCatalog.cs +++ b/IdleSpectator/RelationshipEventCatalog.cs @@ -42,19 +42,24 @@ public static class RelationshipEventCatalog Add("family_removed", 50f, "Relationship", "{a}'s family ends"); Add("find_lover", 45f, "Relationship", "{a} is seeking a lover"); Add("family_group_new", 60f, "Relationship", "{a} forms a family pack"); - Add("family_group_join", 48f, "Relationship", "{a} joins a family pack"); - Add("family_group_leave", 48f, "Relationship", "{a} leaves a family pack"); + Add("family_group_join", 36f, "Relationship", "{a} joins a family pack", createsInterest: false); + Add("family_group_leave", 36f, "Relationship", "{a} leaves a family pack", createsInterest: false); Add("baby_created", 70f, "Relationship", "{a} welcomes a baby"); } - private static void Add(string id, float strength, string category, string label) + private static void Add( + string id, + float strength, + string category, + string label, + bool createsInterest = true) { Entries[id] = new DiscreteEventEntry { Id = id, EventStrength = strength, Category = category, - CreatesInterest = true, + CreatesInterest = createsInterest, LabelTemplate = label }; } diff --git a/IdleSpectator/StatusInterestCatalog.cs b/IdleSpectator/StatusInterestCatalog.cs index cd479e1..332c546 100644 --- a/IdleSpectator/StatusInterestCatalog.cs +++ b/IdleSpectator/StatusInterestCatalog.cs @@ -39,7 +39,7 @@ public static class StatusInterestCatalog Add("tantrum", 52f, "StatusTransformation", true); Add("egg", 40f, "StatusTransformation", false); Add("magnetized", 55f, "StatusTransformation", true); - Add("invincible", 56f, "StatusTransformation", true); + Add("invincible", 40f, "StatusTransformation", false); Add("powerup", 54f, "StatusTransformation", true); Add("enchanted", 52f, "StatusTransformation", true); Add("ash_fever", 50f, "StatusTransformation", true); @@ -49,7 +49,7 @@ public static class StatusInterestCatalog Add("strange_urge", 45f, "StatusTransformation", true); Add("inspired", 42f, "Emotion", true); Add("motivated", 40f, "Emotion", true); - Add("fell_in_love", 55f, "Relationship", true); + Add("fell_in_love", 42f, "Relationship", false); Add("pregnant", 58f, "LifeChapter", true); Add("pregnant_parthenogenesis", 58f, "LifeChapter", true); Add("crying", 50f, "Grief", true, extendsGrief: true); diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index f54d5e3..a59cbc9 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -126,11 +126,11 @@ public static class WorldActivityScanner string label = EventReason.Fight(actor, foe); var candidate = new InterestCandidate { - Key = "scan:evt:" + id, + Key = "combat:" + id, LeadKind = InterestLeadKind.EventLed, Category = "Combat", Source = "scanner", - EventStrength = actionScore, + EventStrength = Mathf.Max(actionScore, 80f), CharacterSignificance = charScore, VisualConfidence = 0.7f, Position = actor.current_position, @@ -149,8 +149,8 @@ public static class WorldActivityScanner CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 18f, - MinWatch = 2f, - MaxWatch = 22f, + MinWatch = 4f, + MaxWatch = 60f, Completion = InterestCompletionKind.CombatActive }; InterestScoring.ScoreCheap(candidate); diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 8fc231c..88fb199 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.20.9", + "version": "0.20.12", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index f19de5d..af249ee 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -15,12 +15,13 @@ "fillScoreMax": 55, "noticeScoreMin": 70, "hotScoreMin": 110, - "dwellFill": 6, - "dwellNormal": 10, - "dwellHot": 14, - "fillRotateSeconds": 10, + "dwellFill": 8, + "dwellNormal": 15, + "dwellHot": 15, + "fillRotateSeconds": 8, + "minCameraDwell": 15, - "maxWatchMoment": 10, + "maxWatchMoment": 15, "maxWatchStatus": 30, "maxWatchGrief": 45, "maxWatchCombat": 60, diff --git a/docs/event-audit.md b/docs/event-audit.md index 16a8022..8f7e4bb 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -17,12 +17,15 @@ Camera interrupt is score-margin only (`cutInMargin`); no Signal/Ambient ownersh | Class | Seconds | JSON field | |-------|--------:|------------| -| Moment / FixedDwell | 10 | `maxWatchMoment` | +| Moment / FixedDwell | 15 | `maxWatchMoment` | | Status | 30 | `maxWatchStatus` | | Grief | 45 | `maxWatchGrief` | | Combat | 60 | `maxWatchCombat` | | Epic world | 50 | `maxWatchEpic` | +Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s). Score-margin interrupts bypass that floor. +Ambient / Character fill is exempt - events may take the camera immediately. + ## Sources (wired) | Source | Primary owner | Reason factory | Notes | @@ -61,9 +64,11 @@ Registry Upsert merges same key and refreshes `LastSeenAt` for sticky combat/sta ## Interrupt / hold / grace -1. Instant cut when `next.TotalScore >= current + cutInMargin` (no settle). -2. Else hold while `InterestCompletion.IsActive` and under MaxWatch. -3. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason. +1. Instant cut when `next.TotalScore >= current + cutInMargin` (no settle; ignores min dwell). +2. Ambient fill: any EventLed may take the camera immediately (no `minCameraDwell`). +3. Else hold while `InterestCompletion.IsActive` and under MaxWatch (moments floor to `minCameraDwell`; combat uses class 60s). +4. Soft peer rotates on EventLed only after `minCameraDwell` (15s). +5. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason. ## Mutation gap triage