From 4491423236d1e49e1b8bd93b96ee52662c576f2e Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 21:17:02 -0500 Subject: [PATCH] Healthy Director --- IdleSpectator/AgentHarness.cs | 18 +++ IdleSpectator/EventCatalogConfig.cs | 46 ++++++ IdleSpectator/Events/Feeds/InterestFeeds.cs | 162 +++++++++++++++++++- IdleSpectator/HappinessEventRouter.cs | 4 +- IdleSpectator/HappinessModels.cs | 13 ++ IdleSpectator/HarnessScenarios.cs | 73 +++++++++ IdleSpectator/event-catalog.json | 56 ++++--- IdleSpectator/mod.json | 4 +- 8 files changed, 350 insertions(+), 26 deletions(-) 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" }