diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 6ffbeaa..9a611d9 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -191,6 +191,23 @@ public static class AgentHarness
break;
}
+ case "force_live_feeds":
+ {
+ bool on = ParseBool(cmd.value, defaultValue: true);
+ ForceLiveEventFeeds = on;
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"force_live_feeds={on}");
+ break;
+ }
+
+ case "drop_clear":
+ {
+ InterestDropLog.Clear();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: "drops_cleared");
+ break;
+ }
+
case "age_current":
{
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f);
@@ -2261,6 +2278,7 @@ public static class AgentHarness
bool ok = published != null;
if (ok)
{
+ InterestFeeds.HarnessEnsureCursorBefore(published.Sequence);
_cmdOk++;
}
else
diff --git a/IdleSpectator/EventCatalogConfig.cs b/IdleSpectator/EventCatalogConfig.cs
index ceceb23..db65f8a 100644
--- a/IdleSpectator/EventCatalogConfig.cs
+++ b/IdleSpectator/EventCatalogConfig.cs
@@ -290,6 +290,7 @@ public static class EventCatalogConfig
InterestCategory = ExtractJsonString(obj, "category") ?? "",
CanonicalMilestoneKey = ExtractJsonString(obj, "canonicalMilestoneKey") ?? "",
StatusOverlapId = ExtractJsonString(obj, "statusOverlapId") ?? "",
+ StatusOverlapKind = ParseStatusOverlapKind(obj, ExtractJsonString(obj, "statusOverlapId")),
VariantsWithRelated = ExtractStringArray(obj, "variantsWithRelated"),
VariantsWithoutRelated = ExtractStringArray(obj, "variantsWithoutRelated")
};
@@ -468,6 +469,51 @@ public static class EventCatalogConfig
return Enum.TryParse(raw, true, out TEnum value) ? value : fallback;
}
+ private static HappinessStatusOverlapKind ParseStatusOverlapKind(string json, string statusOverlapId)
+ {
+ if (string.IsNullOrEmpty(statusOverlapId))
+ {
+ return HappinessStatusOverlapKind.None;
+ }
+
+ HappinessStatusOverlapKind parsed = ParseEnum(
+ json, "statusOverlapKind", HappinessStatusOverlapKind.None);
+ if (parsed != HappinessStatusOverlapKind.None)
+ {
+ return parsed;
+ }
+
+ // Legacy rows without statusOverlapKind: offset end-pings vs onset holds.
+ string id = ExtractJsonString(json, "id") ?? "";
+ if (IsLegacyOffsetOverlap(id))
+ {
+ return HappinessStatusOverlapKind.Offset;
+ }
+
+ return HappinessStatusOverlapKind.Hold;
+ }
+
+ private static bool IsLegacyOffsetOverlap(string effectId)
+ {
+ if (string.IsNullOrEmpty(effectId))
+ {
+ return false;
+ }
+
+ switch (effectId.Trim().ToLowerInvariant())
+ {
+ case "just_laughed":
+ case "just_sang":
+ case "just_swore":
+ case "just_cried":
+ case "just_had_tantrum":
+ case "just_inspired":
+ return true;
+ default:
+ return false;
+ }
+ }
+
private static string[] ExtractStringArray(string json, string key)
{
if (!TryExtractArray(json, key, out string arr) || string.IsNullOrEmpty(arr))
diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs
index 84f1f54..2c703dd 100644
--- a/IdleSpectator/Events/Feeds/InterestFeeds.cs
+++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs
@@ -27,6 +27,30 @@ public static class InterestFeeds
InterestDropLog.Clear();
}
+ /// Keep drain cursor coherent when the happiness router sequence resets.
+ public static void OnHappinessRouterCleared()
+ {
+ _happinessCursor = HappinessEventRouter.CurrentSequence;
+ HappinessDrain.Clear();
+ }
+
+ ///
+ /// Harness: if the router was cleared without syncing the feed cursor, rewind so
+ /// can see the new sequence.
+ ///
+ public static void HarnessEnsureCursorBefore(ulong sequence)
+ {
+ if (sequence == 0)
+ {
+ return;
+ }
+
+ if (_happinessCursor >= sequence)
+ {
+ _happinessCursor = sequence - 1;
+ }
+ }
+
public static ulong HappinessCursor => _happinessCursor;
public static int CivicBoostCount
@@ -728,6 +752,27 @@ public static class InterestFeeds
label = occ.SubjectName + " · " + occ.EffectId;
}
+ InterestCompletionKind completion = grief
+ ? InterestCompletionKind.HappinessGrief
+ : InterestCompletionKind.FixedDwell;
+ string statusId = grief ? "crying" : "";
+
+ if (!grief
+ && !hatchMoment
+ && !alphaMoment
+ && !TryBindStatusOverlapCamera(
+ occ,
+ subject,
+ ref key,
+ ref minWatch,
+ ref maxWatch,
+ ref strength,
+ ref completion,
+ ref statusId))
+ {
+ return;
+ }
+
var candidate = new InterestCandidate
{
Key = key,
@@ -747,16 +792,14 @@ public static class InterestFeeds
AssetId = occ.SubjectSpecies,
SpeciesId = occ.SubjectSpecies,
HappinessEffectId = occ.EffectId,
- StatusId = grief ? "crying" : "",
+ StatusId = statusId,
CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + ttl,
MinWatch = minWatch,
MaxWatch = maxWatch,
- Completion = grief
- ? InterestCompletionKind.HappinessGrief
- : InterestCompletionKind.FixedDwell
+ Completion = completion
};
candidate.ParticipantIds.Add(occ.SubjectId);
if (occ.RelatedId != 0)
@@ -778,6 +821,117 @@ public static class InterestFeeds
}
}
+ ///
+ /// Camera-worthy status-overlap happiness must not FixedDwell-claim a status the unit lacks.
+ /// Hold (onset): StatusPhase while live, else drop. Offset (end-ping): FixedDwell without status.
+ ///
+ private static bool TryBindStatusOverlapCamera(
+ HappinessOccurrence occ,
+ Actor subject,
+ ref string key,
+ ref float minWatch,
+ ref float maxWatch,
+ ref float strength,
+ ref InterestCompletionKind completion,
+ ref string statusId)
+ {
+ if (occ == null || string.IsNullOrEmpty(occ.StatusOverlapId))
+ {
+ return true;
+ }
+
+ HappinessStatusOverlapKind kind = ResolveStatusOverlapKind(occ);
+ if (kind == HappinessStatusOverlapKind.None)
+ {
+ return true;
+ }
+
+ string overlapId = occ.StatusOverlapId.Trim();
+ StatusInterestEntry statusEntry = EventCatalog.Status.GetOrFallback(overlapId);
+ if (!EventCatalog.IsCameraWorthy(statusEntry))
+ {
+ return true;
+ }
+
+ bool hasStatus = StatusGameApi.HasStatus(subject, overlapId)
+ || InterestCompletionHasStatus(subject, overlapId);
+ if (hasStatus)
+ {
+ key = "status:" + overlapId + ":" + occ.SubjectId;
+ statusId = overlapId;
+ completion = InterestCompletionKind.StatusPhase;
+ minWatch = Mathf.Max(minWatch, 2f);
+ maxWatch = Mathf.Max(maxWatch, InterestScoringConfig.W.maxWatchStatus);
+ strength = Mathf.Max(strength, statusEntry.EventStrength);
+ return true;
+ }
+
+ if (kind == HappinessStatusOverlapKind.Offset)
+ {
+ // End-ping after the status cleared - short FixedDwell, keep happiness key.
+ maxWatch = Mathf.Min(maxWatch, 12f);
+ return true;
+ }
+
+ InterestDropLog.Record("status_overlap_absent", occ.EffectId ?? overlapId);
+ return false;
+ }
+
+ private static HappinessStatusOverlapKind ResolveStatusOverlapKind(HappinessOccurrence occ)
+ {
+ if (occ.StatusOverlapKind != HappinessStatusOverlapKind.None)
+ {
+ return occ.StatusOverlapKind;
+ }
+
+ if (string.IsNullOrEmpty(occ.StatusOverlapId))
+ {
+ return HappinessStatusOverlapKind.None;
+ }
+
+ string id = (occ.EffectId ?? "").Trim().ToLowerInvariant();
+ switch (id)
+ {
+ case "just_laughed":
+ case "just_sang":
+ case "just_swore":
+ case "just_cried":
+ case "just_had_tantrum":
+ case "just_inspired":
+ return HappinessStatusOverlapKind.Offset;
+ default:
+ break;
+ }
+
+ string clause = (occ.PlainClause ?? "").Trim();
+ if (clause.StartsWith("finishes", StringComparison.OrdinalIgnoreCase)
+ || clause.StartsWith("comes down", StringComparison.OrdinalIgnoreCase)
+ || clause.StartsWith("calms after", StringComparison.OrdinalIgnoreCase)
+ || clause.StartsWith("lets inspiration", StringComparison.OrdinalIgnoreCase))
+ {
+ return HappinessStatusOverlapKind.Offset;
+ }
+
+ return HappinessStatusOverlapKind.Hold;
+ }
+
+ private static bool InterestCompletionHasStatus(Actor actor, string statusId)
+ {
+ if (actor == null || string.IsNullOrEmpty(statusId))
+ {
+ return false;
+ }
+
+ try
+ {
+ return actor.hasStatus(statusId);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
/// Direct registry insert for harness happiness signals (skips drain filters).
public static InterestCandidate RegisterHappinessSignal(
Actor subject,
diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs
index 99282d4..6c85264 100644
--- a/IdleSpectator/HappinessEventRouter.cs
+++ b/IdleSpectator/HappinessEventRouter.cs
@@ -70,6 +70,7 @@ public static class HappinessEventRouter
_lastEffectId = "";
_counterSubjectId = 0;
_nextSequence = 1;
+ InterestFeeds.OnHappinessRouterCleared();
}
/// Push listener for spectator interest intake (in addition to ).
@@ -539,7 +540,8 @@ public static class HappinessEventRouter
Context = entry.Context,
InterestCategory = entry.InterestCategory,
CanonicalMilestoneKey = entry.CanonicalMilestoneKey,
- StatusOverlapId = entry.StatusOverlapId
+ StatusOverlapId = entry.StatusOverlapId,
+ StatusOverlapKind = entry.StatusOverlapKind
};
}
diff --git a/IdleSpectator/HappinessModels.cs b/IdleSpectator/HappinessModels.cs
index dcdf90d..7c3ab04 100644
--- a/IdleSpectator/HappinessModels.cs
+++ b/IdleSpectator/HappinessModels.cs
@@ -31,6 +31,17 @@ public enum HappinessOverlapPolicy
Continuation
}
+///
+/// How a happiness relates to camera hold.
+/// Hold = onset claim (require live status); Offset = end-ping FixedDwell when status is gone.
+///
+public enum HappinessStatusOverlapKind
+{
+ None,
+ Hold,
+ Offset
+}
+
public enum HappinessRelationRole
{
None,
@@ -69,6 +80,7 @@ public sealed class HappinessCatalogEntry
public float EventStrength = 40f;
public string CanonicalMilestoneKey = "";
public string StatusOverlapId = "";
+ public HappinessStatusOverlapKind StatusOverlapKind = HappinessStatusOverlapKind.None;
public string[] VariantsWithRelated = Array.Empty();
public string[] VariantsWithoutRelated = Array.Empty();
public bool IsFallback;
@@ -102,6 +114,7 @@ public sealed class HappinessOccurrence
public string InterestCategory = "";
public string CanonicalMilestoneKey = "";
public string StatusOverlapId = "";
+ public HappinessStatusOverlapKind StatusOverlapKind = HappinessStatusOverlapKind.None;
public int SampledCount = 1;
public int TotalAffectedCount = 1;
public string CivEventKey = "";
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index e3b8be8..66bf0f6 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -47,6 +47,9 @@ internal static class HarnessScenarios
case "camera_presentability":
case "presentability":
return CameraPresentability();
+ case "status_overlap_camera":
+ case "status_overlap":
+ return StatusOverlapCamera();
case "director_action_priority":
case "action_priority":
return DirectorActionPriority();
@@ -1025,6 +1028,76 @@ internal static class HarnessScenarios
Step("cp90", "fast_timing", value: "false"),
Step("cp99", "snapshot"),
+ Nested("cp_status_overlap", "status_overlap_camera"),
+ };
+ }
+
+ ///
+ /// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks.
+ /// Hold while status is live; offset end-pings still register briefly without it.
+ ///
+ private static List StatusOverlapCamera()
+ {
+ return new List
+ {
+ Step("so0", "dismiss_windows"),
+ Step("so1", "wait_world"),
+ Step("so2", "set_setting", expect: "enabled", value: "true"),
+ Step("so3", "fast_timing", value: "true"),
+ Step("so4", "spawn", asset: "human", count: 1),
+ Step("so5", "pick_unit", asset: "human"),
+ Step("so6", "spectator", value: "off"),
+ Step("so7", "spectator", value: "on"),
+ Step("so8", "focus", asset: "auto"),
+ Step("so9", "interest_end_session"),
+ Step("so9b", "interest_expire_pending", value: ""),
+ Step("so9c", "force_live_feeds", value: "true"),
+
+ // Orphan onset: just_cursed without cursed status must not own the camera.
+ Step("so10", "happiness_reset"),
+ Step("so11", "drop_clear"),
+ Step("so12", "happiness_remember_only", value: "just_cursed"),
+ Step("so13", "interest_feeds_tick"),
+ Step("so14", "assert", expect: "drop_contains", value: "status_overlap_absent"),
+ Step("so15", "assert", expect: "interest_no_key", value: "just_cursed"),
+ Step("so16", "assert", expect: "interest_no_key", value: "status:cursed"),
+
+ // Same class: possessed onset without status.
+ Step("so20", "drop_clear"),
+ Step("so21", "happiness_remember_only", value: "just_possessed"),
+ Step("so22", "interest_feeds_tick"),
+ Step("so23", "assert", expect: "drop_contains", value: "status_overlap_absent"),
+ Step("so24", "assert", expect: "interest_no_key", value: "just_possessed"),
+
+ // Offset end-ping still registers without the status.
+ Step("so30", "drop_clear"),
+ Step("so31", "happiness_remember_only", value: "just_inspired"),
+ Step("so32", "interest_feeds_tick"),
+ Step("so33", "assert", expect: "interest_has_key", value: "just_inspired"),
+ Step("so34", "interest_expire_pending", value: "just_inspired"),
+
+ // Live cursed status owns StatusPhase; happiness Continuation merges.
+ // Short timer: cursed may not clear via finishStatusEffect (re-applies / sticky).
+ Step("so40", "interest_end_session"),
+ Step("so41", "interest_expire_pending", value: ""),
+ Step("so42", "drop_clear"),
+ Step("so43", "status_apply", value: "cursed", label: "0.4"),
+ Step("so44", "assert", expect: "interest_has_key", value: "status:cursed"),
+ Step("so45", "happiness_remember_only", value: "just_cursed"),
+ Step("so46", "interest_feeds_tick"),
+ Step("so47", "assert", expect: "interest_has_key", value: "status:cursed"),
+ Step("so48", "assert", expect: "interest_no_key", value: "happiness:just_cursed"),
+ Step("so49", "wait", wait: 1.1f),
+ Step("so50", "assert", expect: "activity_status_active", value: "cursed", label: "false"),
+ Step("so50b", "age_current", wait: 3f),
+ Step("so50c", "director_run", wait: 1.2f),
+ Step("so51", "interest_end_session"),
+ Step("so52", "interest_expire_pending", value: "status:cursed"),
+ Step("so53", "assert", expect: "interest_no_key", value: "status:cursed"),
+
+ Step("so90", "force_live_feeds", value: "false"),
+ Step("so91", "fast_timing", value: "false"),
+ Step("so99", "snapshot"),
};
}
diff --git a/IdleSpectator/event-catalog.json b/IdleSpectator/event-catalog.json
index 3ea2bf2..c37c48e 100644
--- a/IdleSpectator/event-catalog.json
+++ b/IdleSpectator/event-catalog.json
@@ -2,7 +2,7 @@
"modVersion": "0.25.1",
"title": "IdleSpectator event catalog",
"updated": "2026-07-16",
- "note": "Authored IdleSpectator event catalog. Score ladder (EventStrength): 95-100 epic world (war/kingdom fall/major disaster); 88-94 major spectacle; 78-87 kingdom politics / peak grief; 70-77 life milestones (notice band); 55-69 notable beats; 40-54 warm social; 28-39 soft daily Signal; ≤25 B-only FX. decisions.action = infinitive after \"decides to\"; other domains use label/variants. scoring-model.json remains global formula (margins/multipliers).",
+ "note": "Authored IdleSpectator event catalog. Score ladder (EventStrength): 95-100 epic world (war/kingdom fall/major disaster); 88-94 major spectacle; 78-87 kingdom politics / peak grief; 70-77 life milestones (notice band); 55-69 notable beats; 40-54 warm social; 28-39 soft daily Signal; \u226425 B-only FX. decisions.action = infinitive after \"decides to\"; other domains use label/variants. scoring-model.json remains global formula (margins/multipliers).",
"decisions": [
{
"id": "find_lover",
@@ -299,7 +299,8 @@
"eats and feels better",
"enjoys a meal"
],
- "statusOverlapId": "just_ate"
+ "statusOverlapId": "just_ate",
+ "statusOverlapKind": "hold"
},
{
"id": "just_received_gift",
@@ -390,7 +391,8 @@
"has a bad dream",
"is troubled by a bad dream"
],
- "statusOverlapId": "had_bad_dream"
+ "statusOverlapId": "had_bad_dream",
+ "statusOverlapKind": "hold"
},
{
"id": "had_good_dream",
@@ -409,7 +411,8 @@
"has a good dream",
"dreams pleasantly"
],
- "statusOverlapId": "had_good_dream"
+ "statusOverlapId": "had_good_dream",
+ "statusOverlapKind": "hold"
},
{
"id": "had_nightmare",
@@ -428,7 +431,8 @@
"has a nightmare",
"is haunted by a nightmare"
],
- "statusOverlapId": "had_nightmare"
+ "statusOverlapId": "had_nightmare",
+ "statusOverlapKind": "hold"
},
{
"id": "slept_outside",
@@ -665,7 +669,8 @@
"is smitten"
],
"canonicalMilestoneKey": "milestone_lover",
- "statusOverlapId": "fell_in_love"
+ "statusOverlapId": "fell_in_love",
+ "statusOverlapKind": "hold"
},
{
"id": "just_had_child",
@@ -756,7 +761,8 @@
"finishes laughing",
"had a good laugh"
],
- "statusOverlapId": "laughing"
+ "statusOverlapId": "laughing",
+ "statusOverlapKind": "offset"
},
{
"id": "just_sang",
@@ -775,7 +781,8 @@
"finishes singing",
"sings with feeling"
],
- "statusOverlapId": "singing"
+ "statusOverlapId": "singing",
+ "statusOverlapKind": "offset"
},
{
"id": "just_swore",
@@ -794,7 +801,8 @@
"finishes swearing",
"lets out a string of curses"
],
- "statusOverlapId": "swearing"
+ "statusOverlapId": "swearing",
+ "statusOverlapKind": "offset"
},
{
"id": "just_cried",
@@ -813,7 +821,8 @@
"finishes crying",
"cries it out"
],
- "statusOverlapId": "crying"
+ "statusOverlapId": "crying",
+ "statusOverlapKind": "offset"
},
{
"id": "just_talked_gossip",
@@ -850,7 +859,8 @@
"is startled",
"jumps in surprise"
],
- "statusOverlapId": "surprised"
+ "statusOverlapId": "surprised",
+ "statusOverlapKind": "hold"
},
{
"id": "just_born",
@@ -887,7 +897,8 @@
"is magnetized",
"feels a magnetic pull"
],
- "statusOverlapId": "magnetized"
+ "statusOverlapId": "magnetized",
+ "statusOverlapKind": "hold"
},
{
"id": "just_forced_power",
@@ -924,7 +935,8 @@
"is possessed",
"falls under possession"
],
- "statusOverlapId": "possessed"
+ "statusOverlapId": "possessed",
+ "statusOverlapKind": "hold"
},
{
"id": "strange_urge",
@@ -943,7 +955,8 @@
"feels a strange urge",
"is gripped by a strange urge"
],
- "statusOverlapId": "strange_urge"
+ "statusOverlapId": "strange_urge",
+ "statusOverlapKind": "hold"
},
{
"id": "just_had_tantrum",
@@ -962,7 +975,8 @@
"finishes a tantrum",
"calms after a tantrum"
],
- "statusOverlapId": "tantrum"
+ "statusOverlapId": "tantrum",
+ "statusOverlapKind": "offset"
},
{
"id": "just_felt_the_divine",
@@ -999,7 +1013,8 @@
"is enchanted",
"feels an enchantment take hold"
],
- "statusOverlapId": "enchanted"
+ "statusOverlapId": "enchanted",
+ "statusOverlapKind": "hold"
},
{
"id": "just_inspired",
@@ -1018,7 +1033,8 @@
"comes down from inspiration",
"lets inspiration settle into contentment"
],
- "statusOverlapId": "inspired"
+ "statusOverlapId": "inspired",
+ "statusOverlapKind": "offset"
},
{
"id": "wrote_book",
@@ -1182,7 +1198,8 @@
"is cursed",
"falls under a curse"
],
- "statusOverlapId": "cursed"
+ "statusOverlapId": "cursed",
+ "statusOverlapKind": "hold"
},
{
"id": "starving",
@@ -1201,7 +1218,8 @@
"is starving",
"goes hungry"
],
- "statusOverlapId": "starving"
+ "statusOverlapId": "starving",
+ "statusOverlapKind": "hold"
},
{
"id": "conquered_city",
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 7e1dd10..5b5519d 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.25.47",
- "description": "AFK Idle Spectator (I) + Lore (L). Focus hold repair mid-idle.",
+ "version": "0.25.50",
+ "description": "AFK Idle Spectator (I) + Lore (L). Status-overlap camera hold gate.",
"GUID": "com.dazed.idlespectator"
}