Healthy Director

This commit is contained in:
DazedAnon 2026-07-16 21:17:02 -05:00
parent 57d2e70476
commit 4491423236
8 changed files with 350 additions and 26 deletions

View file

@ -191,6 +191,23 @@ public static class AgentHarness
break; 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": case "age_current":
{ {
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f); float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f);
@ -2261,6 +2278,7 @@ public static class AgentHarness
bool ok = published != null; bool ok = published != null;
if (ok) if (ok)
{ {
InterestFeeds.HarnessEnsureCursorBefore(published.Sequence);
_cmdOk++; _cmdOk++;
} }
else else

View file

@ -290,6 +290,7 @@ public static class EventCatalogConfig
InterestCategory = ExtractJsonString(obj, "category") ?? "", InterestCategory = ExtractJsonString(obj, "category") ?? "",
CanonicalMilestoneKey = ExtractJsonString(obj, "canonicalMilestoneKey") ?? "", CanonicalMilestoneKey = ExtractJsonString(obj, "canonicalMilestoneKey") ?? "",
StatusOverlapId = ExtractJsonString(obj, "statusOverlapId") ?? "", StatusOverlapId = ExtractJsonString(obj, "statusOverlapId") ?? "",
StatusOverlapKind = ParseStatusOverlapKind(obj, ExtractJsonString(obj, "statusOverlapId")),
VariantsWithRelated = ExtractStringArray(obj, "variantsWithRelated"), VariantsWithRelated = ExtractStringArray(obj, "variantsWithRelated"),
VariantsWithoutRelated = ExtractStringArray(obj, "variantsWithoutRelated") VariantsWithoutRelated = ExtractStringArray(obj, "variantsWithoutRelated")
}; };
@ -468,6 +469,51 @@ public static class EventCatalogConfig
return Enum.TryParse(raw, true, out TEnum value) ? value : fallback; 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) private static string[] ExtractStringArray(string json, string key)
{ {
if (!TryExtractArray(json, key, out string arr) || string.IsNullOrEmpty(arr)) if (!TryExtractArray(json, key, out string arr) || string.IsNullOrEmpty(arr))

View file

@ -27,6 +27,30 @@ public static class InterestFeeds
InterestDropLog.Clear(); InterestDropLog.Clear();
} }
/// <summary>Keep drain cursor coherent when the happiness router sequence resets.</summary>
public static void OnHappinessRouterCleared()
{
_happinessCursor = HappinessEventRouter.CurrentSequence;
HappinessDrain.Clear();
}
/// <summary>
/// Harness: if the router was cleared without syncing the feed cursor, rewind so
/// <see cref="HarnessDrainOnce"/> can see the new sequence.
/// </summary>
public static void HarnessEnsureCursorBefore(ulong sequence)
{
if (sequence == 0)
{
return;
}
if (_happinessCursor >= sequence)
{
_happinessCursor = sequence - 1;
}
}
public static ulong HappinessCursor => _happinessCursor; public static ulong HappinessCursor => _happinessCursor;
public static int CivicBoostCount public static int CivicBoostCount
@ -728,6 +752,27 @@ public static class InterestFeeds
label = occ.SubjectName + " · " + occ.EffectId; 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 var candidate = new InterestCandidate
{ {
Key = key, Key = key,
@ -747,16 +792,14 @@ public static class InterestFeeds
AssetId = occ.SubjectSpecies, AssetId = occ.SubjectSpecies,
SpeciesId = occ.SubjectSpecies, SpeciesId = occ.SubjectSpecies,
HappinessEffectId = occ.EffectId, HappinessEffectId = occ.EffectId,
StatusId = grief ? "crying" : "", StatusId = statusId,
CorrelationKey = occ.CorrelationKey, CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime, CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + ttl, ExpiresAt = Time.unscaledTime + ttl,
MinWatch = minWatch, MinWatch = minWatch,
MaxWatch = maxWatch, MaxWatch = maxWatch,
Completion = grief Completion = completion
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
}; };
candidate.ParticipantIds.Add(occ.SubjectId); candidate.ParticipantIds.Add(occ.SubjectId);
if (occ.RelatedId != 0) if (occ.RelatedId != 0)
@ -778,6 +821,117 @@ public static class InterestFeeds
} }
} }
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>Direct registry insert for harness happiness signals (skips drain filters).</summary> /// <summary>Direct registry insert for harness happiness signals (skips drain filters).</summary>
public static InterestCandidate RegisterHappinessSignal( public static InterestCandidate RegisterHappinessSignal(
Actor subject, Actor subject,

View file

@ -70,6 +70,7 @@ public static class HappinessEventRouter
_lastEffectId = ""; _lastEffectId = "";
_counterSubjectId = 0; _counterSubjectId = 0;
_nextSequence = 1; _nextSequence = 1;
InterestFeeds.OnHappinessRouterCleared();
} }
/// <summary>Push listener for spectator interest intake (in addition to <see cref="DrainSince"/>).</summary> /// <summary>Push listener for spectator interest intake (in addition to <see cref="DrainSince"/>).</summary>
@ -539,7 +540,8 @@ public static class HappinessEventRouter
Context = entry.Context, Context = entry.Context,
InterestCategory = entry.InterestCategory, InterestCategory = entry.InterestCategory,
CanonicalMilestoneKey = entry.CanonicalMilestoneKey, CanonicalMilestoneKey = entry.CanonicalMilestoneKey,
StatusOverlapId = entry.StatusOverlapId StatusOverlapId = entry.StatusOverlapId,
StatusOverlapKind = entry.StatusOverlapKind
}; };
} }

View file

@ -31,6 +31,17 @@ public enum HappinessOverlapPolicy
Continuation Continuation
} }
/// <summary>
/// How a happiness <see cref="HappinessCatalogEntry.StatusOverlapId"/> relates to camera hold.
/// Hold = onset claim (require live status); Offset = end-ping FixedDwell when status is gone.
/// </summary>
public enum HappinessStatusOverlapKind
{
None,
Hold,
Offset
}
public enum HappinessRelationRole public enum HappinessRelationRole
{ {
None, None,
@ -69,6 +80,7 @@ public sealed class HappinessCatalogEntry
public float EventStrength = 40f; public float EventStrength = 40f;
public string CanonicalMilestoneKey = ""; public string CanonicalMilestoneKey = "";
public string StatusOverlapId = ""; public string StatusOverlapId = "";
public HappinessStatusOverlapKind StatusOverlapKind = HappinessStatusOverlapKind.None;
public string[] VariantsWithRelated = Array.Empty<string>(); public string[] VariantsWithRelated = Array.Empty<string>();
public string[] VariantsWithoutRelated = Array.Empty<string>(); public string[] VariantsWithoutRelated = Array.Empty<string>();
public bool IsFallback; public bool IsFallback;
@ -102,6 +114,7 @@ public sealed class HappinessOccurrence
public string InterestCategory = ""; public string InterestCategory = "";
public string CanonicalMilestoneKey = ""; public string CanonicalMilestoneKey = "";
public string StatusOverlapId = ""; public string StatusOverlapId = "";
public HappinessStatusOverlapKind StatusOverlapKind = HappinessStatusOverlapKind.None;
public int SampledCount = 1; public int SampledCount = 1;
public int TotalAffectedCount = 1; public int TotalAffectedCount = 1;
public string CivEventKey = ""; public string CivEventKey = "";

View file

@ -47,6 +47,9 @@ internal static class HarnessScenarios
case "camera_presentability": case "camera_presentability":
case "presentability": case "presentability":
return CameraPresentability(); return CameraPresentability();
case "status_overlap_camera":
case "status_overlap":
return StatusOverlapCamera();
case "director_action_priority": case "director_action_priority":
case "action_priority": case "action_priority":
return DirectorActionPriority(); return DirectorActionPriority();
@ -1025,6 +1028,76 @@ internal static class HarnessScenarios
Step("cp90", "fast_timing", value: "false"), Step("cp90", "fast_timing", value: "false"),
Step("cp99", "snapshot"), Step("cp99", "snapshot"),
Nested("cp_status_overlap", "status_overlap_camera"),
};
}
/// <summary>
/// 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.
/// </summary>
private static List<HarnessCommand> StatusOverlapCamera()
{
return new List<HarnessCommand>
{
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"),
}; };
} }

View file

@ -2,7 +2,7 @@
"modVersion": "0.25.1", "modVersion": "0.25.1",
"title": "IdleSpectator event catalog", "title": "IdleSpectator event catalog",
"updated": "2026-07-16", "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": [ "decisions": [
{ {
"id": "find_lover", "id": "find_lover",
@ -299,7 +299,8 @@
"eats and feels better", "eats and feels better",
"enjoys a meal" "enjoys a meal"
], ],
"statusOverlapId": "just_ate" "statusOverlapId": "just_ate",
"statusOverlapKind": "hold"
}, },
{ {
"id": "just_received_gift", "id": "just_received_gift",
@ -390,7 +391,8 @@
"has a bad dream", "has a bad dream",
"is troubled by a bad dream" "is troubled by a bad dream"
], ],
"statusOverlapId": "had_bad_dream" "statusOverlapId": "had_bad_dream",
"statusOverlapKind": "hold"
}, },
{ {
"id": "had_good_dream", "id": "had_good_dream",
@ -409,7 +411,8 @@
"has a good dream", "has a good dream",
"dreams pleasantly" "dreams pleasantly"
], ],
"statusOverlapId": "had_good_dream" "statusOverlapId": "had_good_dream",
"statusOverlapKind": "hold"
}, },
{ {
"id": "had_nightmare", "id": "had_nightmare",
@ -428,7 +431,8 @@
"has a nightmare", "has a nightmare",
"is haunted by a nightmare" "is haunted by a nightmare"
], ],
"statusOverlapId": "had_nightmare" "statusOverlapId": "had_nightmare",
"statusOverlapKind": "hold"
}, },
{ {
"id": "slept_outside", "id": "slept_outside",
@ -665,7 +669,8 @@
"is smitten" "is smitten"
], ],
"canonicalMilestoneKey": "milestone_lover", "canonicalMilestoneKey": "milestone_lover",
"statusOverlapId": "fell_in_love" "statusOverlapId": "fell_in_love",
"statusOverlapKind": "hold"
}, },
{ {
"id": "just_had_child", "id": "just_had_child",
@ -756,7 +761,8 @@
"finishes laughing", "finishes laughing",
"had a good laugh" "had a good laugh"
], ],
"statusOverlapId": "laughing" "statusOverlapId": "laughing",
"statusOverlapKind": "offset"
}, },
{ {
"id": "just_sang", "id": "just_sang",
@ -775,7 +781,8 @@
"finishes singing", "finishes singing",
"sings with feeling" "sings with feeling"
], ],
"statusOverlapId": "singing" "statusOverlapId": "singing",
"statusOverlapKind": "offset"
}, },
{ {
"id": "just_swore", "id": "just_swore",
@ -794,7 +801,8 @@
"finishes swearing", "finishes swearing",
"lets out a string of curses" "lets out a string of curses"
], ],
"statusOverlapId": "swearing" "statusOverlapId": "swearing",
"statusOverlapKind": "offset"
}, },
{ {
"id": "just_cried", "id": "just_cried",
@ -813,7 +821,8 @@
"finishes crying", "finishes crying",
"cries it out" "cries it out"
], ],
"statusOverlapId": "crying" "statusOverlapId": "crying",
"statusOverlapKind": "offset"
}, },
{ {
"id": "just_talked_gossip", "id": "just_talked_gossip",
@ -850,7 +859,8 @@
"is startled", "is startled",
"jumps in surprise" "jumps in surprise"
], ],
"statusOverlapId": "surprised" "statusOverlapId": "surprised",
"statusOverlapKind": "hold"
}, },
{ {
"id": "just_born", "id": "just_born",
@ -887,7 +897,8 @@
"is magnetized", "is magnetized",
"feels a magnetic pull" "feels a magnetic pull"
], ],
"statusOverlapId": "magnetized" "statusOverlapId": "magnetized",
"statusOverlapKind": "hold"
}, },
{ {
"id": "just_forced_power", "id": "just_forced_power",
@ -924,7 +935,8 @@
"is possessed", "is possessed",
"falls under possession" "falls under possession"
], ],
"statusOverlapId": "possessed" "statusOverlapId": "possessed",
"statusOverlapKind": "hold"
}, },
{ {
"id": "strange_urge", "id": "strange_urge",
@ -943,7 +955,8 @@
"feels a strange urge", "feels a strange urge",
"is gripped by a strange urge" "is gripped by a strange urge"
], ],
"statusOverlapId": "strange_urge" "statusOverlapId": "strange_urge",
"statusOverlapKind": "hold"
}, },
{ {
"id": "just_had_tantrum", "id": "just_had_tantrum",
@ -962,7 +975,8 @@
"finishes a tantrum", "finishes a tantrum",
"calms after a tantrum" "calms after a tantrum"
], ],
"statusOverlapId": "tantrum" "statusOverlapId": "tantrum",
"statusOverlapKind": "offset"
}, },
{ {
"id": "just_felt_the_divine", "id": "just_felt_the_divine",
@ -999,7 +1013,8 @@
"is enchanted", "is enchanted",
"feels an enchantment take hold" "feels an enchantment take hold"
], ],
"statusOverlapId": "enchanted" "statusOverlapId": "enchanted",
"statusOverlapKind": "hold"
}, },
{ {
"id": "just_inspired", "id": "just_inspired",
@ -1018,7 +1033,8 @@
"comes down from inspiration", "comes down from inspiration",
"lets inspiration settle into contentment" "lets inspiration settle into contentment"
], ],
"statusOverlapId": "inspired" "statusOverlapId": "inspired",
"statusOverlapKind": "offset"
}, },
{ {
"id": "wrote_book", "id": "wrote_book",
@ -1182,7 +1198,8 @@
"is cursed", "is cursed",
"falls under a curse" "falls under a curse"
], ],
"statusOverlapId": "cursed" "statusOverlapId": "cursed",
"statusOverlapKind": "hold"
}, },
{ {
"id": "starving", "id": "starving",
@ -1201,7 +1218,8 @@
"is starving", "is starving",
"goes hungry" "goes hungry"
], ],
"statusOverlapId": "starving" "statusOverlapId": "starving",
"statusOverlapKind": "hold"
}, },
{ {
"id": "conquered_city", "id": "conquered_city",

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.25.47", "version": "0.25.50",
"description": "AFK Idle Spectator (I) + Lore (L). Focus hold repair mid-idle.", "description": "AFK Idle Spectator (I) + Lore (L). Status-overlap camera hold gate.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }