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.

This commit is contained in:
DazedAnon 2026-07-15 16:10:44 -05:00
parent 79e1a93633
commit d260472cc4
8 changed files with 920 additions and 18 deletions

View file

@ -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<InterestCandidate>(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);

View file

@ -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
};
}
/// <summary>
/// Full director gap matrix: resume, variety, lifecycle edges, scoring, happiness filters.
/// </summary>
private static List<HarnessCommand> DirectorGaps()
{
return new List<HarnessCommand>
{
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<HarnessCommand> Discovery()
{
return new List<HarnessCommand>

View file

@ -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;
}
/// <summary>Harness: end the active scene (may resume an Epic-interrupted one).</summary>
public static void HarnessEndCurrent(string reason = "harness")
{
EndCurrent(Time.unscaledTime, reason);
}
/// <summary>Harness: drop ForceActive so FixedDwell / quiet grace can finish the scene.</summary>
public static void HarnessReleaseForceActive()
{
if (_current != null)
{
_current.ForceActive = false;
}
}
/// <summary>
/// Harness: mark the current scene inactive for <paramref name="inactiveSeconds"/>
/// so quiet-grace completion can be asserted.
/// </summary>
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);
}
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
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;
}
/// <summary>Harness: run one SelectNext without switching (lead-kind probe).</summary>
public static InterestCandidate HarnessPeekNext()
{
return SelectNext(Time.unscaledTime);
}
public static void OnSpectatorEnabled()
{
InterestRegistry.Clear();

View file

@ -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<string, float> 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<string, float> 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;
}
/// <summary>Harness: drain happiness once; returns how many occurrences were ingested.</summary>
public static int HarnessDrainOnce()
{
DrainHappiness();
return HappinessDrain.Count;
}
/// <summary>Harness: stamp a civic boost as AggregateSummary would.</summary>
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);
}

View file

@ -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<string, InterestCandidate> 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<string, InterestCandidate> 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)

View file

@ -27,6 +27,29 @@ public static class InterestVariety
{
RecentMix.Clear();
CooldownUntil.Clear();
LastPickHadBothPools = false;
}
/// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary>
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)

View file

@ -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"
}

View file

@ -31,6 +31,9 @@ REGRESSION_SCENARIOS=(
retarget_stability
input_exit
director_tiers
director_lifecycle
interest_happiness
director_gaps
discovery
world_log
chronicle_smoke