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