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.

This commit is contained in:
DazedAnon 2026-07-16 00:27:37 -05:00
parent c253b5dd9c
commit ff824c76a1
11 changed files with 239 additions and 59 deletions

View file

@ -327,7 +327,8 @@ public static class PhenotypeInterestCatalog
"{a} shows {id}",
ambientStrength: 28f,
signalIds: null,
signalStrength: 60f);
signalStrength: 60f,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{

View file

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

View file

@ -12,7 +12,7 @@ namespace IdleSpectator;
/// </summary>
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
/// <summary>
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
/// 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.
/// </summary>
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);
}
/// <summary>Character fill / low-score stroll - events should cut in freely.</summary>
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;
}

View file

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

View file

@ -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;
/// <summary>Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt.</summary>
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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