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