From d260472cc4eb4df001beaa7949aa52e8595bdaaa Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 16:10:44 -0500 Subject: [PATCH] Implement new interest management commands in AgentHarness, enhancing functionality for interest variety, session control, and happiness suppression. Introduce new harness scenarios for testing director gaps and coverage. Update InterestDirector and InterestFeeds to support new features, including improved civic boost handling and interest expiration. Increment version in mod.json to reflect these changes. --- IdleSpectator/AgentHarness.cs | 527 +++++++++++++++++++++++++++++- IdleSpectator/HarnessScenarios.cs | 135 ++++++++ IdleSpectator/InterestDirector.cs | 81 +++++ IdleSpectator/InterestFeeds.cs | 109 +++++- IdleSpectator/InterestRegistry.cs | 58 ++++ IdleSpectator/InterestVariety.cs | 23 ++ IdleSpectator/mod.json | 2 +- scripts/harness-run.sh | 3 + 8 files changed, 920 insertions(+), 18 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 909be24..59bf56f 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1794,6 +1794,232 @@ public static class AgentHarness DoInterestForceSession(cmd); break; + case "interest_variety_clear": + InterestVariety.Clear(); + _cmdOk++; + Emit(cmd, ok: true, detail: "variety_cleared"); + break; + + case "interest_variety_seed": + { + // value = "events:chars" e.g. "2:10" + string raw = (cmd.value ?? "").Trim(); + int events = 0; + int chars = 0; + int colon = raw.IndexOf(':'); + if (colon >= 0) + { + int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out events); + int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out chars); + } + else + { + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out events); + } + + InterestVariety.HarnessSeedMix(events, chars); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"seeded events={events} chars={chars} share={InterestVariety.EventShare():0.##} samples={InterestVariety.MixSampleCount}"); + break; + } + + case "interest_end_session": + InterestDirector.HarnessEndCurrent(cmd.value ?? "harness"); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"ended key={InterestDirector.CurrentKey} interrupted={InterestDirector.InterruptedKey}"); + break; + + case "interest_release_force": + InterestDirector.HarnessReleaseForceActive(); + _cmdOk++; + Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}"); + break; + + case "interest_mark_inactive": + { + float ago = ParseFloat(cmd.value, 1f); + InterestDirector.HarnessMarkInactive(ago); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey}"); + break; + } + + case "interest_clear_follow": + { + Actor related = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); + if (related == null) + { + related = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + // Prefer a different living unit than the current follow. + Actor follow = InterestDirector.CurrentCandidate?.FollowUnit; + if (related != null && follow != null && related == follow) + { + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor != follow) + { + try + { + if (actor.isAlive()) + { + related = actor; + break; + } + } + catch + { + // continue + } + } + } + } + + bool ok = InterestDirector.HarnessClearFollowForHandoff(related); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"cleared related={SafeName(related)} key={InterestDirector.CurrentKey}"); + break; + } + + case "interest_expire_pending": + { + string needle = cmd.value ?? ""; + InterestRegistry.ForceExpireContaining(needle, Time.unscaledTime); + _cmdOk++; + Emit(cmd, ok: true, detail: $"expired needle='{needle}' pending={InterestRegistry.PendingCount}"); + break; + } + + case "interest_feeds_tick": + { + int drained = InterestFeeds.HarnessDrainOnce(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"drained={drained} cursor={InterestFeeds.HappinessCursor} pending={InterestRegistry.PendingCount}"); + break; + } + + case "interest_peek_next": + { + InterestCandidate peek = InterestDirector.HarnessPeekNext(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: peek == null + ? "peek=null" + : $"peek={peek.Key} lead={peek.LeadKind} tier={peek.Urgency} label={peek.Label}"); + break; + } + + case "interest_civic_boost": + { + string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_started_war"; + string kingdom = !string.IsNullOrEmpty(cmd.label) ? cmd.label : "harness"; + float amount = ParseFloat(cmd.expect, 40f); + InterestFeeds.HarnessStampCivicBoost(kingdom, effectId, amount); + _cmdOk++; + Emit(cmd, ok: true, detail: $"civic kingdom={kingdom} effect={effectId} amt={amount:0.#}"); + break; + } + + case "happiness_suppress": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (focus == null && InterestDirector.CurrentCandidate != null) + { + focus = InterestDirector.CurrentCandidate.FollowUnit; + } + + if (focus == null) + { + focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "death_child"; + if (focus == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit for happiness_suppress"); + break; + } + + HappinessEventRouter.PublishSuppressed(focus, effectId, HappinessSourceHook.Harness); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"suppressed={effectId} unit={SafeName(focus)} seq={HappinessEventRouter.CurrentSequence}"); + break; + } + + case "happiness_remember_only": + { + // Remember a Signal for DrainSince without immediate Ingest/Register. + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (focus == null && InterestDirector.CurrentCandidate != null) + { + focus = InterestDirector.CurrentCandidate.FollowUnit; + } + + if (focus == null) + { + focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "death_lover"; + if (focus == null || !focus.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no living unit for happiness_remember_only"); + break; + } + + HappinessOccurrence published = HappinessEventRouter.PublishApplied( + focus, + effectId, + HappinessGameApi.ResolveAmount(effectId), + HappinessSourceHook.Harness, + related: null, + role: HappinessRelationRole.None, + forceRing: true); + bool ok = published != null; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"remembered={effectId} seq={HappinessEventRouter.CurrentSequence} cursor={InterestFeeds.HappinessCursor} unit={SafeName(focus)}"); + break; + } + case "set_setting": DoSetSetting(cmd); break; @@ -2202,19 +2428,54 @@ public static class AgentHarness InterestTier tier = ParseTier(cmd.tier, InterestTier.Action); string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + tier) : cmd.label; string keySuffix = string.IsNullOrEmpty(cmd.expect) ? label.Replace(" ", "_") : cmd.expect; - bool force = ParseBool(cmd.value, defaultValue: false); + ParseInterestInjectOptions( + cmd.value, + out bool force, + out InterestLeadKind? leadOpt, + out float eventStrength, + out float charSig, + out float maxWatch, + out float ttl, + out bool resumable); + + InterestLeadKind lead = leadOpt + ?? (tier >= InterestTier.Curiosity && tier < InterestTier.Action + ? InterestLeadKind.CharacterLed + : InterestLeadKind.EventLed); + if (eventStrength < 0f) + { + eventStrength = 50f + (int)tier * 15f; + } + + if (charSig < 0f) + { + charSig = lead == InterestLeadKind.CharacterLed ? eventStrength : 10f; + } + + if (maxWatch < 0f) + { + maxWatch = 30f; + } + + if (ttl < 0f) + { + ttl = 60f; + } + InterestCandidate c = InterestFeeds.RegisterHarness( follow, tier, label, keySuffix, - lead: tier >= InterestTier.Curiosity && tier < InterestTier.Action - ? InterestLeadKind.CharacterLed - : InterestLeadKind.EventLed, - completion: force ? InterestCompletionKind.Manual : InterestCompletionKind.FixedDwell, - forceActive: force, - eventStrength: 50f + (int)tier * 15f, - maxWatch: 30f); + lead, + force ? InterestCompletionKind.Manual : InterestCompletionKind.FixedDwell, + force, + eventStrength, + charSig, + maxWatch, + ttl, + related: null, + resumable); if (c == null) { _cmdFail++; @@ -2223,7 +2484,108 @@ public static class AgentHarness } _cmdOk++; - Emit(cmd, ok: true, detail: $"injected key={c.Key} tier={c.Urgency} pending={InterestRegistry.PendingCount}"); + Emit( + cmd, + ok: true, + detail: $"injected key={c.Key} tier={c.Urgency} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} pending={InterestRegistry.PendingCount}"); + } + + private static void ParseInterestInjectOptions( + string raw, + out bool force, + out InterestLeadKind? lead, + out float eventStrength, + out float charSig, + out float maxWatch, + out float ttl, + out bool resumable) + { + force = false; + lead = null; + eventStrength = -1f; + charSig = -1f; + maxWatch = -1f; + ttl = -1f; + resumable = true; + if (string.IsNullOrEmpty(raw)) + { + return; + } + + string[] parts = raw.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + string p = parts[i].Trim(); + if (p.Equals("true", StringComparison.OrdinalIgnoreCase) + || p.Equals("force", StringComparison.OrdinalIgnoreCase)) + { + force = true; + continue; + } + + if (p.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + force = false; + continue; + } + + int eq = p.IndexOf('='); + if (eq <= 0) + { + if (p.Equals("event", StringComparison.OrdinalIgnoreCase) + || p.Equals("eventled", StringComparison.OrdinalIgnoreCase)) + { + lead = InterestLeadKind.EventLed; + } + else if (p.Equals("character", StringComparison.OrdinalIgnoreCase) + || p.Equals("char", StringComparison.OrdinalIgnoreCase) + || p.Equals("characterled", StringComparison.OrdinalIgnoreCase)) + { + lead = InterestLeadKind.CharacterLed; + } + + continue; + } + + string key = p.Substring(0, eq).Trim().ToLowerInvariant(); + string val = p.Substring(eq + 1).Trim(); + switch (key) + { + case "force": + force = ParseBool(val, false); + break; + case "lead": + if (val.StartsWith("c", StringComparison.OrdinalIgnoreCase)) + { + lead = InterestLeadKind.CharacterLed; + } + else + { + lead = InterestLeadKind.EventLed; + } + + break; + case "evt": + case "event": + case "strength": + eventStrength = ParseFloat(val, eventStrength); + break; + case "char": + case "charsig": + charSig = ParseFloat(val, charSig); + break; + case "max": + case "maxwatch": + maxWatch = ParseFloat(val, maxWatch); + break; + case "ttl": + ttl = ParseFloat(val, ttl); + break; + case "resumable": + resumable = ParseBool(val, true); + break; + } + } } private static void DoInterestForceSession(HarnessCommand cmd) @@ -2525,9 +2887,15 @@ public static class AgentHarness { float want = ParseFloat(cmd.value, 0.5f); float have = InterestVariety.EventShare(); - // Soft check: within 0.35 of target when enough samples exist. - pass = InterestVariety.MixSampleCount < 3 || Mathf.Abs(have - want) <= 0.35f; - detail = $"share={have:0.##} want~={want:0.##} samples={InterestVariety.MixSampleCount}"; + float tol = 0.35f; + if (!string.IsNullOrEmpty(cmd.label) + && float.TryParse(cmd.label, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedTol)) + { + tol = parsedTol; + } + + pass = InterestVariety.MixSampleCount < 3 || Mathf.Abs(have - want) <= tol; + detail = $"share={have:0.##} want~={want:0.##} tol={tol:0.##} samples={InterestVariety.MixSampleCount}"; break; } case "happiness_drain_seq": @@ -2537,6 +2905,23 @@ public static class AgentHarness detail = $"happiness_seq={have}"; break; } + case "happiness_cursor": + { + ulong have = InterestFeeds.HappinessCursor; + if (!string.IsNullOrEmpty(cmd.value) + && ulong.TryParse(cmd.value, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong want)) + { + pass = have == want; + detail = $"cursor={have} want={want}"; + } + else + { + pass = true; + detail = $"cursor={have}"; + } + + break; + } case "interest_has_key": { string want = (cmd.value ?? cmd.expect ?? "").Trim(); @@ -2572,6 +2957,12 @@ public static class AgentHarness break; } } + + if (!have && InterestRegistry.CountKeysContaining(want) > 0) + { + have = true; + found = "registry:" + want; + } } } @@ -2579,6 +2970,118 @@ public static class AgentHarness detail = $"want='{want}' found='{found}' pending={InterestRegistry.PendingCount}"; break; } + case "interest_no_key": + { + string want = (cmd.value ?? cmd.expect ?? "").Trim(); + int n = InterestRegistry.CountKeysContaining(want); + bool inCurrent = !string.IsNullOrEmpty(want) + && (InterestDirector.CurrentKey ?? "") + .IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0; + pass = n == 0 && !inCurrent; + detail = $"want_absent='{want}' count={n} current={InterestDirector.CurrentKey}"; + break; + } + case "interest_registry_count": + { + string needle = cmd.label ?? ""; + int want = ParseCountExpect(cmd, defaultValue: 0); + int have = string.IsNullOrEmpty(needle) + ? InterestRegistry.Count + : InterestRegistry.CountKeysContaining(needle); + pass = have == want; + detail = $"count={have} want={want} needle='{needle}'"; + break; + } + case "interrupted_key": + case "interest_interrupted": + { + string want = (cmd.value ?? "").Trim(); + string have = InterestDirector.InterruptedKey ?? ""; + if (string.IsNullOrEmpty(want)) + { + pass = !string.IsNullOrEmpty(have); + } + else if (want.Equals("empty", StringComparison.OrdinalIgnoreCase) + || want.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + pass = string.IsNullOrEmpty(have); + } + else + { + pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0; + } + + detail = $"interrupted='{have}' want='{want}'"; + break; + } + case "interest_lead": + { + string want = (cmd.value ?? "EventLed").Trim(); + InterestLeadKind? have = InterestDirector.CurrentLeadKind; + pass = have.HasValue + && have.Value.ToString().Equals(want, StringComparison.OrdinalIgnoreCase); + detail = $"lead={have} want={want} key={InterestDirector.CurrentKey}"; + break; + } + case "interest_peek_lead": + { + string want = (cmd.value ?? "EventLed").Trim(); + InterestCandidate peek = InterestDirector.HarnessPeekNext(); + pass = peek != null + && peek.LeadKind.ToString().Equals(want, StringComparison.OrdinalIgnoreCase); + detail = peek == null + ? "peek=null" + : $"peek_lead={peek.LeadKind} want={want} key={peek.Key}"; + break; + } + case "interest_score_order": + { + // value = winnerNeedle, label = loserNeedle — winner TotalScore must be >= loser. + string winnerNeedle = (cmd.value ?? "").Trim(); + string loserNeedle = (cmd.label ?? "").Trim(); + InterestCandidate win = null; + InterestCandidate lose = null; + var all = new System.Collections.Generic.List(64); + InterestRegistry.CopyPending(all); + if (InterestDirector.CurrentCandidate != null) + { + all.Add(InterestDirector.CurrentCandidate); + } + + for (int i = 0; i < all.Count; i++) + { + InterestCandidate c = all[i]; + if (c?.Key == null) + { + continue; + } + + if (win == null + && c.Key.IndexOf(winnerNeedle, StringComparison.OrdinalIgnoreCase) >= 0) + { + win = c; + } + + if (lose == null + && c.Key.IndexOf(loserNeedle, StringComparison.OrdinalIgnoreCase) >= 0) + { + lose = c; + } + } + + pass = win != null && lose != null && win.TotalScore >= lose.TotalScore; + detail = $"win={win?.Key}({win?.TotalScore:0.#}) lose={lose?.Key}({lose?.TotalScore:0.#})"; + break; + } + case "civic_boost": + { + string effectId = (cmd.value ?? "").Trim(); + bool want = string.IsNullOrEmpty(cmd.label) || ParseBool(cmd.label, true); + bool have = InterestFeeds.HasCivicBoostContaining(effectId); + pass = have == want; + detail = $"civic={have} want={want} effect='{effectId}' count={InterestFeeds.CivicBoostCount}"; + break; + } case "in_grace": { bool want = ParseBool(cmd.value, defaultValue: true); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 93d29dc..4e6bdbd 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -32,6 +32,9 @@ internal static class HarnessScenarios return DirectorLifecycle(); case "interest_happiness": return InterestHappiness(); + case "director_gaps": + case "director_coverage": + return DirectorGaps(); case "discovery": return Discovery(); case "world_log": @@ -130,6 +133,7 @@ internal static class HarnessScenarios Nested("reg_director", "director_tiers"), Nested("reg_director_life", "director_lifecycle"), Nested("reg_interest_happiness", "interest_happiness"), + Nested("reg_director_gaps", "director_gaps"), Nested("reg_discovery", "discovery"), Nested("reg_worldlog", "world_log"), Nested("reg_chronicle", "chronicle_smoke"), @@ -674,6 +678,137 @@ internal static class HarnessScenarios }; } + /// + /// Full director gap matrix: resume, variety, lifecycle edges, scoring, happiness filters. + /// + private static List DirectorGaps() + { + return new List + { + Step("dg0", "dismiss_windows"), + Step("dg1", "wait_world"), + Step("dg2", "set_setting", expect: "enabled", value: "true"), + Step("dg3", "fast_timing", value: "true"), + Step("dg4", "spawn", asset: "sheep", count: 2), + Step("dg5", "spectator", value: "off"), + Step("dg6", "spectator", value: "on"), + Step("dg7", "focus", asset: "sheep"), + Step("dg8", "interest_variety_clear"), + + // --- Epic preempts Story; Story does not preempt itself past Action rules --- + Step("dg10", "interest_force_session", asset: "sheep", label: "HoldStory", tier: "Story", expect: "hold_story"), + Step("dg11", "assert", expect: "current_tier", value: "Story"), + Step("dg12", "trigger_interest", asset: "sheep", label: "ActionBlocked", tier: "Action"), + Step("dg13", "age_current", wait: 1.2f), + Step("dg14", "director_run", wait: 1.2f), + Step("dg15", "assert", expect: "current_tier", value: "Story"), + Step("dg16", "assert", expect: "tip_contains", value: "HoldStory"), + Step("dg17", "trigger_interest", asset: "sheep", label: "EpicOverStory", tier: "Epic"), + Step("dg18", "age_current", wait: 1.2f), + Step("dg19", "director_run", wait: 1.2f), + Step("dg20", "assert", expect: "current_tier", value: "Epic"), + Step("dg21", "assert", expect: "tip_contains", value: "EpicOverStory"), + + // --- Resume after Epic: Action hold interrupted then restored --- + Step("dg30", "spectator", value: "off"), + Step("dg31", "spectator", value: "on"), + Step("dg32", "focus", asset: "sheep"), + Step("dg33", "interest_force_session", asset: "sheep", label: "HoldResume", tier: "Action", expect: "hold_resume"), + Step("dg34", "assert", expect: "session_key", value: "hold_resume"), + Step("dg35", "trigger_interest", asset: "sheep", label: "EpicInterrupt", tier: "Epic"), + Step("dg36", "age_current", wait: 1.2f), + Step("dg37", "director_run", wait: 1.2f), + Step("dg38", "assert", expect: "current_tier", value: "Epic"), + Step("dg39", "assert", expect: "interrupted_key", value: "hold_resume"), + // Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action. + Step("dg40", "age_current", wait: 9f), + Step("dg41", "director_run", wait: 1.2f), + Step("dg42", "assert", expect: "tip_contains", value: "HoldResume"), + Step("dg43", "assert", expect: "current_tier", value: "Action"), + Step("dg44", "assert", expect: "interrupted_key", value: "none"), + + // --- Max cap ends a forced scene --- + Step("dg50", "interest_force_session", asset: "sheep", label: "CapMe", tier: "Action", expect: "cap_me"), + Step("dg51", "age_current", wait: 9f), + Step("dg52", "director_run", wait: 1.2f), + Step("dg54", "assert", expect: "interest_no_key", value: "cap_me"), + + // --- Quiet grace: inactive scene ends after grace --- + Step("dg60", "interest_force_session", asset: "sheep", label: "QuietEnd", tier: "Action", expect: "quiet_end"), + Step("dg61", "interest_mark_inactive", value: "1.0"), + Step("dg62", "assert", expect: "session_active", value: "false"), + Step("dg63", "director_run", wait: 1.2f), + Step("dg64", "assert", expect: "interest_no_key", value: "quiet_end"), + + // --- Stale expiry / dedupe refresh --- + Step("dg70", "interest_inject", asset: "sheep", label: "StaleTip", tier: "Curiosity", expect: "stale_tip", value: "lead=character;ttl=0.01"), + Step("dg71", "assert", expect: "interest_has_key", value: "stale_tip"), + Step("dg72", "interest_expire_pending", value: "stale_tip"), + Step("dg73", "assert", expect: "interest_no_key", value: "stale_tip"), + Step("dg74", "interest_inject", asset: "sheep", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=20"), + Step("dg75", "interest_inject", asset: "sheep", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=90"), + Step("dg76", "assert", expect: "interest_registry_count", value: "1", label: "refresh_a"), + + // --- Death handoff: FollowUnit cleared → RelatedUnit --- + Step("dg80", "interest_force_session", asset: "sheep", label: "HandoffHold", tier: "Action", expect: "handoff_hold"), + Step("dg81", "interest_clear_follow", asset: "sheep"), + Step("dg82", "director_run", wait: 0.8f), + Step("dg83", "assert", expect: "session_key", value: "handoff_hold"), + Step("dg84", "assert", expect: "session_active", value: "true"), + + // --- Soft 70/30: character-heavy mix prefers event when both pools exist --- + Step("dg90", "interest_end_session"), + Step("dg91", "interest_variety_clear"), + Step("dg91b", "interest_expire_pending", value: ""), + Step("dg92", "interest_variety_seed", value: "2:10"), + Step("dg93", "assert", expect: "interest_event_share", value: "0.17", label: "0.2"), + Step("dg94", "interest_inject", asset: "sheep", label: "EvtWin", tier: "Curiosity", expect: "evt_win", value: "lead=event;evt=80"), + Step("dg95", "interest_inject", asset: "sheep", label: "CharLose", tier: "Curiosity", expect: "char_lose", value: "lead=character;char=95"), + Step("dg96", "assert", expect: "interest_peek_lead", value: "EventLed"), + // Empty event pool → character-led, no invented events + Step("dg97", "interest_expire_pending", value: "evt_win"), + Step("dg98", "assert", expect: "interest_peek_lead", value: "CharacterLed"), + + // --- Event strength beats idle celebrity (within event pool / score order) --- + Step("dg100", "interest_expire_pending", value: ""), + Step("dg101", "interest_inject", asset: "sheep", label: "StrongEvt", tier: "Action", expect: "strong_evt", value: "lead=event;evt=95;char=5"), + Step("dg102", "interest_inject", asset: "sheep", label: "CelebWeak", tier: "Action", expect: "celeb_weak", value: "lead=event;evt=10;char=99"), + Step("dg103", "assert", expect: "interest_score_order", value: "strong_evt", label: "celeb_weak"), + + // --- Ambient happiness never owns camera; psychopath suppress; drain duplex; aggregate --- + Step("dg110", "interest_force_session", asset: "sheep", label: "ActionKeep", tier: "Action", expect: "action_keep"), + Step("dg111", "happiness_apply", value: "just_ate"), + Step("dg112", "interest_feeds_tick"), + Step("dg113", "age_current", wait: 1.2f), + Step("dg114", "director_run", wait: 1.2f), + Step("dg115", "assert", expect: "current_tier", value: "Action"), + Step("dg116", "assert", expect: "tip_contains", value: "ActionKeep"), + Step("dg117", "assert", expect: "interest_no_key", value: "just_ate"), + + Step("dg120", "happiness_suppress", value: "death_child"), + Step("dg121", "interest_feeds_tick"), + Step("dg122", "assert", expect: "interest_no_key", value: "death_child"), + + Step("dg130", "happiness_reset"), + Step("dg131", "happiness_remember_only", value: "death_lover", label: "Ava"), + Step("dg132", "interest_feeds_tick"), + Step("dg133", "assert", expect: "interest_has_key", value: "death_lover"), + Step("dg134", "assert", expect: "interest_registry_count", value: "1", label: "death_lover"), + Step("dg135", "interest_feeds_tick"), + Step("dg136", "assert", expect: "interest_registry_count", value: "1", label: "death_lover"), + + Step("dg140", "happiness_burst", value: "just_started_war", count: 32), + Step("dg141", "interest_feeds_tick"), + Step("dg142", "assert", expect: "interest_no_key", value: "just_started_war"), + Step("dg143", "interest_civic_boost", value: "just_started_war", label: "harness", expect: "40"), + Step("dg144", "assert", expect: "civic_boost", value: "just_started_war"), + + Step("dg190", "assert", expect: "no_bad"), + Step("dg191", "fast_timing", value: "false"), + Step("dg199", "snapshot"), + }; + } + private static List Discovery() { return new List diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index e67d60c..89a32cd 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -68,6 +68,12 @@ public static class InterestDirector public static InterestCandidate CurrentCandidate => _current; + public static string InterruptedKey => + _interrupted == null ? "" : (_interrupted.Key ?? ""); + + public static InterestLeadKind? CurrentLeadKind => + _current == null ? (InterestLeadKind?)null : _current.LeadKind; + public static bool InInputGrace => SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace; @@ -127,6 +133,81 @@ public static class InterestDirector return true; } + /// Harness: end the active scene (may resume an Epic-interrupted one). + public static void HarnessEndCurrent(string reason = "harness") + { + EndCurrent(Time.unscaledTime, reason); + } + + /// Harness: drop ForceActive so FixedDwell / quiet grace can finish the scene. + public static void HarnessReleaseForceActive() + { + if (_current != null) + { + _current.ForceActive = false; + } + } + + /// + /// Harness: mark the current scene inactive for + /// so quiet-grace completion can be asserted. + /// + public static void HarnessMarkInactive(float inactiveSeconds) + { + if (_current == null) + { + return; + } + + _current.ForceActive = false; + if (_current.Completion == InterestCompletionKind.Manual) + { + _current.Completion = InterestCompletionKind.FixedDwell; + } + + float now = Time.unscaledTime; + float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch * 0.35f) + 0.5f; + _currentStartedAt = now - pastActive; + _lastSwitchAt = _currentStartedAt; + _inactiveSince = now - Mathf.Max(0f, inactiveSeconds); + } + + /// Harness: clear FollowUnit while keeping RelatedUnit for death handoff. + public static bool HarnessClearFollowForHandoff(Actor related) + { + if (_current == null) + { + return false; + } + + if (related != null && related.isAlive()) + { + _current.RelatedUnit = related; + try + { + _current.RelatedId = related.getID(); + } + catch + { + _current.RelatedId = 0; + } + } + + if (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()) + { + return false; + } + + _current.FollowUnit = null; + return true; + } + + /// Harness: run one SelectNext without switching (lead-kind probe). + public static InterestCandidate HarnessPeekNext() + { + return SelectNext(Time.unscaledTime); + } + public static void OnSpectatorEnabled() { InterestRegistry.Clear(); diff --git a/IdleSpectator/InterestFeeds.cs b/IdleSpectator/InterestFeeds.cs index 0d4bd53..23d8411 100644 --- a/IdleSpectator/InterestFeeds.cs +++ b/IdleSpectator/InterestFeeds.cs @@ -25,6 +25,59 @@ public static class InterestFeeds CivicBoostUntil.Clear(); } + public static ulong HappinessCursor => _happinessCursor; + + public static int CivicBoostCount + { + get + { + int n = 0; + foreach (KeyValuePair kv in CivicBoostUntil) + { + if (kv.Key != null && !kv.Key.EndsWith("|amt") && kv.Value > Time.unscaledTime) + { + n++; + } + } + + return n; + } + } + + public static bool HasCivicBoostContaining(string effectId) + { + if (string.IsNullOrEmpty(effectId)) + { + return CivicBoostCount > 0; + } + + foreach (KeyValuePair kv in CivicBoostUntil) + { + if (kv.Key != null + && !kv.Key.EndsWith("|amt") + && kv.Key.IndexOf(effectId, System.StringComparison.OrdinalIgnoreCase) >= 0 + && kv.Value > Time.unscaledTime) + { + return true; + } + } + + return false; + } + + /// Harness: drain happiness once; returns how many occurrences were ingested. + public static int HarnessDrainOnce() + { + DrainHappiness(); + return HappinessDrain.Count; + } + + /// Harness: stamp a civic boost as AggregateSummary would. + public static void HarnessStampCivicBoost(string kingdom, string effectId, float amount) + { + StampCivicBoost(kingdom ?? "harness", effectId ?? "", amount); + } + public static void Tick() { DrainHappiness(); @@ -151,6 +204,37 @@ public static class InterestFeeds bool forceActive, float eventStrength, float maxWatch) + { + return RegisterHarness( + follow, + urgency, + label, + keySuffix, + lead, + completion, + forceActive, + eventStrength, + characterSignificance: lead == InterestLeadKind.CharacterLed ? eventStrength : 10f, + maxWatch, + ttlSeconds: 60f, + related: null, + resumable: true); + } + + public static InterestCandidate RegisterHarness( + Actor follow, + InterestTier urgency, + string label, + string keySuffix, + InterestLeadKind lead, + InterestCompletionKind completion, + bool forceActive, + float eventStrength, + float characterSignificance, + float maxWatch, + float ttlSeconds, + Actor related, + bool resumable) { if (follow == null || !follow.isAlive()) { @@ -159,6 +243,7 @@ public static class InterestFeeds long id = SafeId(follow); string key = "harness:" + (keySuffix ?? urgency.ToString()) + ":" + id; + float now = Time.unscaledTime; var candidate = new InterestCandidate { Key = key, @@ -167,22 +252,36 @@ public static class InterestFeeds Category = CategoryForTier(urgency, ""), Source = "harness", EventStrength = eventStrength, - CharacterSignificance = lead == InterestLeadKind.CharacterLed ? eventStrength : 10f, + CharacterSignificance = characterSignificance, VisualConfidence = 0.9f, Position = follow.current_position, FollowUnit = follow, + RelatedUnit = related, SubjectId = id, Label = label ?? ("Harness " + urgency), AssetId = follow.asset != null ? follow.asset.id : "harness", SpeciesId = follow.asset != null ? follow.asset.id : "", - CreatedAt = Time.unscaledTime, - LastSeenAt = Time.unscaledTime, - ExpiresAt = Time.unscaledTime + 60f, + CreatedAt = now, + LastSeenAt = now, + ExpiresAt = now + (ttlSeconds > 0f ? ttlSeconds : 60f), MinWatch = 1f, MaxWatch = maxWatch > 0f ? maxWatch : 20f, Completion = completion, - ForceActive = forceActive + ForceActive = forceActive, + Resumable = resumable }; + if (related != null) + { + try + { + candidate.RelatedId = related.getID(); + } + catch + { + candidate.RelatedId = 0; + } + } + InterestScoring.ScoreCheap(candidate); return InterestRegistry.Upsert(candidate); } diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 104a884..346a762 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -132,6 +132,64 @@ public static class InterestRegistry } } + public static int CountKeysContaining(string needle) + { + if (string.IsNullOrEmpty(needle)) + { + return Count; + } + + lock (Gate) + { + int n = 0; + foreach (KeyValuePair kv in ByKey) + { + InterestCandidate c = kv.Value; + if (c?.Key != null + && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + n++; + continue; + } + + if (c?.HappinessEffectId != null + && c.HappinessEffectId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + { + n++; + } + } + + return n; + } + } + + public static void ForceExpireContaining(string needle, float now) + { + lock (Gate) + { + Scratch.Clear(); + foreach (KeyValuePair kv in ByKey) + { + InterestCandidate c = kv.Value; + if (c == null) + { + continue; + } + + bool match = string.IsNullOrEmpty(needle) + || (c.Key != null + && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0); + if (match) + { + c.ExpiresAt = now - 0.01f; + Scratch.Add(c); + } + } + } + + ExpireStale(now); + } + public static bool TryGet(string key, out InterestCandidate candidate) { lock (Gate) diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index d6171a0..5ae6cf0 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -27,6 +27,29 @@ public static class InterestVariety { RecentMix.Clear(); CooldownUntil.Clear(); + LastPickHadBothPools = false; + } + + /// Harness: seed the rolling mix meter without touching cooldowns. + public static void HarnessSeedMix(int eventLedCount, int characterLedCount) + { + RecentMix.Clear(); + int e = Mathf.Max(0, eventLedCount); + int c = Mathf.Max(0, characterLedCount); + for (int i = 0; i < e; i++) + { + RecentMix.Add(InterestLeadKind.EventLed); + } + + for (int i = 0; i < c; i++) + { + RecentMix.Add(InterestLeadKind.CharacterLed); + } + + while (RecentMix.Count > MixWindow) + { + RecentMix.RemoveAt(0); + } } public static void NoteSelection(InterestCandidate chosen) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 070ac8f..84dce33 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.14.14", + "version": "0.14.16", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index 4688957..f86b763 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -31,6 +31,9 @@ REGRESSION_SCENARIOS=( retarget_stability input_exit director_tiers + director_lifecycle + interest_happiness + director_gaps discovery world_log chronicle_smoke