using System; using System.Collections.Generic; using ai.behaviours; using HarmonyLib; using UnityEngine; namespace IdleSpectator; public sealed class EventLiveRelationshipResult { public bool Passed; public string Detail = ""; public int Failures; } /// /// Phase 2: vanilla relationship fires with ForceLiveEventFeeds. /// Includes negative join (Beh Continue without family → outcome_unconfirmed). /// Always detaches members before FamilyManager.removeObject so leftover /// families cannot NRE vanilla Family.isFull / getNearbyFamily. /// public static class EventLiveRelationshipHarness { public static EventLiveRelationshipResult Run(string outputDirectory, Actor seedUnit) { var result = new EventLiveRelationshipResult(); if (EventLiveCoverageLedger.RowCount == 0) { EventLiveCoverageLedger.SeedFromCatalogs(); } var fails = new List(16); var spawned = new List(32); var families = new List(8); bool prevForce = AgentHarness.ForceLiveEventFeeds; bool prevRel = AgentHarness.ForceRelationshipFeeds; AgentHarness.ForceLiveEventFeeds = true; AgentHarness.ForceRelationshipFeeds = true; try { Actor a = Track(SpawnHumanNear(seedUnit), spawned); Actor b = Track(SpawnHumanNear(a ?? seedUnit), spawned); if (a == null || b == null) { result.Detail = "could not spawn humans for relationship live"; return result; } AgeOutOfSpawnWindow(a); AgeOutOfSpawnWindow(b); // --- set_lover --- InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:", Time.unscaledTime); try { a.becomeLoversWith(b); } catch (Exception ex) { Fail(fails, "set_lover", "exception:" + ex.Message); } if (a.hasLover()) { Claim( "set_lover", "id", "becomeLoversWith", InterestRegistry.CountKeysContaining("rel:set_lover") > 0 ? "busy_bypass=ForceLiveEventFeeds" : "state_has_lover interest_optional"); } else { Fail(fails, "set_lover", "no_lover_after_bond"); } // --- clear_lover --- InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:clear_lover", Time.unscaledTime); try { a.setLover(null); try { b.setLover(null); } catch { // ignore } } catch (Exception ex) { Fail(fails, "clear_lover", "exception:" + ex.Message); } if (!a.hasLover() && InterestRegistry.CountKeysContaining("rel:clear_lover") > 0) { Claim("clear_lover", "id", "setLover(null)", "busy_bypass=ForceLiveEventFeeds"); } else if (!a.hasLover()) { Fail(fails, "clear_lover", "no_interest_key drops=" + InterestDropLog.FormatRecent(4)); } else { Fail(fails, "clear_lover", "still_has_lover"); } // --- add_child --- Actor parent = Track(SpawnHumanNear(a), spawned); Actor child = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(parent); AgeOutOfSpawnWindow(child); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:add_child", Time.unscaledTime); try { child.setParent1(parent); } catch (Exception ex) { Fail(fails, "add_child", "exception:" + ex.Message); } if (InterestRegistry.CountKeysContaining("rel:add_child") > 0) { Claim("add_child", "id", "setParent1", "busy_bypass=ForceLiveEventFeeds"); } else { Fail(fails, "add_child", "no_key drops=" + InterestDropLog.FormatRecent(4)); } // --- new_family --- Actor founder = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(founder); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:new_family", Time.unscaledTime); Family family = null; try { family = World.world.families.newFamily(founder, founder.current_tile, null); TrackFamily(family, families); } catch (Exception ex) { Fail(fails, "new_family", "exception:" + ex.Message); } if (family != null && founder.hasFamily() && InterestRegistry.CountKeysContaining("rel:new_family") > 0) { Claim("new_family", "id", "FamilyManager.newFamily", "busy_bypass=ForceLiveEventFeeds"); } else if (family != null && founder.hasFamily()) { Fail(fails, "new_family", "no_interest_key"); } else { Fail(fails, "new_family", "family_null_or_unset"); } // --- family_group_join POSITIVE (Beh near existing family) --- Actor joiner = Track(SpawnHumanNear(founder), spawned); AgeOutOfSpawnWindow(joiner); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:family_group_join", Time.unscaledTime); try { TryMoveNear(joiner, founder); try { if (founder.subspecies != null) { joiner.setSubspecies(founder.subspecies); } } catch { // ignore } SyncFamilyUnits(); Family nearby = null; try { nearby = World.world.families.getNearbyFamily(joiner.asset, joiner.current_tile); } catch { nearby = null; } EventOutcome.ActorFlags before = EventOutcome.Snapshot(joiner); BehResult br = new BehFamilyGroupJoin().execute(joiner); bool gained = EventOutcome.GainedFamily(before, joiner); if (gained && InterestRegistry.CountKeysContaining("rel:family_group_join") > 0) { Claim( "family_group_join", "id", "BehFamilyGroupJoin", "busy_bypass=ForceLiveEventFeeds nearby=" + (nearby != null)); } else if (gained) { Fail(fails, "family_group_join", "gained_but_no_key"); } else { if (family != null && !joiner.hasFamily()) { joiner.setFamily(family); } Claim( "family_group_join", joiner.hasFamily() ? "pipeline" : "unsupported", "BehFamilyGroupJoin", joiner.hasFamily() ? "shared=setFamily fallback; getNearbyFamily=" + (nearby != null) : "beh_and_setFamily_failed"); } } catch (Exception ex) { Fail(fails, "family_group_join", "beh_exception:" + ex.Message); } // --- family_group_join NEGATIVE --- Actor lonely = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(lonely); try { // This is a negative outcome probe, so actually isolate the actor from the // founder family. SpawnHumanNear(a) intentionally puts it inside the positive // probe's search area and can otherwise produce a legitimate join. TryIsolateFromPeers(lonely); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:family_group_join", Time.unscaledTime); int keysBefore = InterestRegistry.CountKeysContaining("rel:family_group_join"); EventOutcome.ActorFlags beforeNeg = EventOutcome.Snapshot(lonely); new BehFamilyGroupJoin().execute(lonely); bool gainedNeg = EventOutcome.GainedFamily(beforeNeg, lonely); int keysAfter = InterestRegistry.CountKeysContaining("rel:family_group_join"); bool unconfirmed = DropMentions("outcome_unconfirmed", "family_group_join"); if (gainedNeg || keysAfter > keysBefore) { Fail( fails, "family_group_join_neg", $"false_positive gained={gainedNeg} keys={keysBefore}->{keysAfter}"); } else if (!unconfirmed) { Fail( fails, "family_group_join_neg", "expected_outcome_unconfirmed drops=" + InterestDropLog.FormatRecent(6)); } } catch (Exception ex) { Fail(fails, "family_group_join_neg", "exception:" + ex.Message); } // --- family_removed BEFORE leave (need living members still attached) --- if (family != null) { InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:family_removed", Time.unscaledTime); try { SyncFamilyUnits(); Actor anchor = null; if (founder != null && founder.hasFamily() && founder.family == family) { anchor = founder; } else if (joiner != null && joiner.hasFamily() && joiner.family == family) { anchor = joiner; } RelationshipEventPatches.NoteFamilyRemoveAnchor(anchor); World.world.families.removeObject(family); families.Remove(family); DetachUnitsStillHoldingFamily(family); if (InterestRegistry.CountKeysContaining("rel:family_removed") > 0) { Claim("family_removed", "id", "FamilyManager.removeObject", "busy_bypass=ForceLiveEventFeeds"); } else if (anchor != null) { RelationshipInterestFeed.EmitFamily("family_removed", family, anchor); Claim( "family_removed", InterestRegistry.CountKeysContaining("rel:family_removed") > 0 ? "feed" : "unsupported", "FamilyManager.removeObject", "prefix_miss EmitFamily_fallback"); } else { Claim( "family_removed", "unsupported", "FamilyManager.removeObject", "no_key_after_remove"); } } catch (Exception ex) { Fail(fails, "family_removed", "exception:" + ex.Message); } family = null; } else { Claim("family_removed", "unsupported", "FamilyManager.removeObject", "no_family"); } // --- find_lover (must stay !hasLover: isolate so Beh does not instantly bond) --- Actor seeker = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(seeker); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:find_lover", Time.unscaledTime); try { TryIsolateFromPeers(seeker); try { if (seeker.hasLover()) { seeker.setLover(null); } } catch { // ignore } BehResult brSeek = new BehFindLover().execute(seeker); if (EventOutcome.StillSeekingLover(seeker, brSeek) && InterestRegistry.CountKeysContaining("rel:find_lover") > 0) { Claim("find_lover", "id", "BehFindLover", "busy_bypass=ForceLiveEventFeeds isolated"); } else if (EventOutcome.StillSeekingLover(seeker, brSeek)) { Fail(fails, "find_lover", "seeking_but_no_key"); } else if (seeker.hasLover()) { Claim( "find_lover", "unsupported", "BehFindLover", "bonded_instead_of_seeking"); } else { Claim("find_lover", "unsupported", "BehFindLover", "not_seeking_or_stop"); } } catch (Exception ex) { Fail(fails, "find_lover", "exception:" + ex.Message); } // --- family_group_new --- Actor packer = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(packer); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:family_group_new", Time.unscaledTime); InterestRegistry.ForceExpireContaining("rel:new_family", Time.unscaledTime); try { EventOutcome.ActorFlags beforeNew = EventOutcome.Snapshot(packer); new BehFamilyGroupNew().execute(packer); bool gainedFam = EventOutcome.GainedFamily(beforeNew, packer); if (packer.hasFamily()) { TrackFamily(packer.family, families); } if (gainedFam && (InterestRegistry.CountKeysContaining("rel:family_group_new") > 0 || InterestRegistry.CountKeysContaining("rel:new_family") > 0)) { Claim("family_group_new", "id", "BehFamilyGroupNew", "busy_bypass=ForceLiveEventFeeds"); } else if (gainedFam) { Fail(fails, "family_group_new", "gained_but_no_key"); } else { Claim("family_group_new", "unsupported", "BehFamilyGroupNew", "did_not_gain_family"); } } catch (Exception ex) { Fail(fails, "family_group_new", "exception:" + ex.Message); } // --- family_group_leave on pack from family_group_new (or dedicated family) --- Actor leaver = null; if (packer != null && packer.hasFamily()) { leaver = packer; } else { Actor leaveHost = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(leaveHost); try { Family leaveFam = World.world.families.newFamily( leaveHost, leaveHost.current_tile, null); TrackFamily(leaveFam, families); leaver = leaveHost; } catch { leaver = null; } } if (leaver != null && leaver.hasFamily()) { InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:family_group_leave", Time.unscaledTime); SyncFamilyUnits(); int prevLimit = leaver.asset != null ? leaver.asset.family_limit : 20; try { int members = CountFamilyMembers(leaver.family); if (members < 1) { members = 1; } if (leaver.asset != null) { leaver.asset.family_limit = Math.Max(0, members - 1); } EventOutcome.ActorFlags beforeLeave = EventOutcome.Snapshot(leaver); BehResult leaveBr = new BehFamilyGroupLeave().execute(leaver); bool lostViaBeh = EventOutcome.LostFamily(beforeLeave, leaver); bool lost = lostViaBeh; if (!lost && leaver.hasFamily()) { leaver.setFamily(null); lost = !leaver.hasFamily(); } if (lost && InterestRegistry.CountKeysContaining("rel:family_group_leave") > 0) { Claim( "family_group_leave", "id", lostViaBeh ? "BehFamilyGroupLeave" : "setFamily(null)", "busy_bypass=ForceLiveEventFeeds leaveBr=" + leaveBr); } else if (lost) { Fail(fails, "family_group_leave", "lost_but_no_key"); } else { Claim( "family_group_leave", "unsupported", "BehFamilyGroupLeave", "could_not_clear_family members=" + members); } } catch (Exception ex) { Fail(fails, "family_group_leave", "exception:" + ex.Message); } finally { try { if (leaver.asset != null) { leaver.asset.family_limit = prevLimit; } } catch { // ignore } } } else { Claim("family_group_leave", "unsupported", "BehFamilyGroupLeave", "no_family_member"); } // --- become_alpha --- Actor alpha = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(alpha); try { Family fam2 = World.world.families.newFamily(alpha, alpha.current_tile, null); TrackFamily(fam2, families); InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("alpha:", Time.unscaledTime); fam2.setAlpha(alpha, true); if (InterestRegistry.CountKeysContaining("alpha:") > 0) { Claim("become_alpha", "id", "Family.setAlpha", "busy_bypass=ForceLiveEventFeeds"); } else { Claim("become_alpha", "unsupported", "Family.setAlpha", "no_interest_key"); } } catch (Exception ex) { Fail(fails, "become_alpha", "exception:" + ex.Message); } // --- baby_created --- Actor mother = Track(SpawnHumanNear(a), spawned); AgeOutOfSpawnWindow(mother); if (mother != null && mother.asset != null && mother.current_tile != null) { InterestDropLog.Clear(); InterestRegistry.ForceExpireContaining("rel:baby_created", Time.unscaledTime); try { ActorData data = new ActorData(); data.asset_id = mother.asset.id; data.id = World.world.map_stats.getNextId("unit"); data.created_time = World.world.getCurWorldTime(); data.x = mother.current_tile.x; data.y = mother.current_tile.y; try { data.parent_id_1 = mother.data.id; } catch { // ignore } City city = null; try { city = mother.city; } catch { city = null; } Actor baby = World.world.units.createBabyActorFromData( data, mother.current_tile, city); Track(baby, spawned); if (baby != null && baby.isAlive() && InterestRegistry.CountKeysContaining("rel:baby_created") > 0) { Claim( "baby_created", "id", "createBabyActorFromData", "busy_bypass=ForceLiveEventFeeds"); } else if (baby != null && baby.isAlive()) { Fail(fails, "baby_created", "alive_but_no_key"); } else { Claim( "baby_created", "unsupported", "createBabyActorFromData", "baby_null_or_dead"); } } catch (Exception ex) { Fail(fails, "baby_created", "exception:" + ex.Message); } } else { Claim("baby_created", "unsupported", "createBabyActorFromData", "no_mother"); } } finally { CleanupTracked(spawned, families); AgentHarness.ForceLiveEventFeeds = prevForce; AgentHarness.ForceRelationshipFeeds = prevRel; } result.Failures = fails.Count; string summary = EventLiveCoverageLedger.Write(outputDirectory); result.Passed = fails.Count == 0; result.Detail = summary + (fails.Count > 0 ? (" first=" + fails[0] + (fails.Count > 1 ? " +" + (fails.Count - 1) : "")) : ""); return result; } private static void Claim(string id, string level, string pipeline, string notes) { EventLiveCoverageLedger.Claim("relationship", id, level, pipeline, notes); } private static void Fail(List fails, string id, string notes) { fails.Add(id + " " + notes); EventLiveCoverageLedger.Claim("relationship", id, "fail", "relationship_live", notes); } private static bool DropMentions(string reason, string needle) { List lines = InterestDropLog.Snapshot(16); for (int i = 0; i < lines.Count; i++) { string line = lines[i] ?? ""; if (line.IndexOf(reason, StringComparison.OrdinalIgnoreCase) < 0) { continue; } if (string.IsNullOrEmpty(needle) || line.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } private static Actor Track(Actor actor, List spawned) { if (actor != null) { spawned.Add(actor); } return actor; } private static void TrackFamily(Family family, List families) { if (family != null && !families.Contains(family)) { families.Add(family); } } private static void SyncFamilyUnits() { try { AccessTools.Method(typeof(FamilyManager), "updateDirtyUnits") ?.Invoke(World.world?.families, null); } catch { // ignore } } private static int CountFamilyMembers(Family family) { if (family == null) { return 0; } try { if (family.units != null && family.units.Count > 0) { return family.units.Count; } } catch { // fall through to scan } int n = 0; try { List alive = World.world?.units?.units_only_alive; if (alive == null) { return 0; } for (int i = 0; i < alive.Count; i++) { Actor unit = alive[i]; try { if (unit != null && unit.isAlive() && unit.hasFamily() && unit.family == family) { n++; } } catch { // ignore } } } catch { // ignore } return n; } /// /// Detach every living member. Prevents zombie family refs that NRE /// Family.isFull via null getActorAsset. /// private static void DetachAllMembers(Family family) { DetachUnitsStillHoldingFamily(family); } private static void DetachUnitsStillHoldingFamily(Family family) { if (family == null) { return; } var toDetach = new List(16); try { List alive = World.world?.units?.units_only_alive; if (alive != null) { for (int i = 0; i < alive.Count; i++) { Actor unit = alive[i]; try { if (unit != null && unit.isAlive() && unit.hasFamily() && unit.family == family) { toDetach.Add(unit); } } catch { // ignore } } } } catch { // ignore } for (int i = 0; i < toDetach.Count; i++) { try { toDetach[i].setFamily(null); } catch { // ignore } } } private static void CleanupTracked(List spawned, List families) { // Families first (detach then remove). for (int i = families.Count - 1; i >= 0; i--) { Family f = families[i]; try { DetachAllMembers(f); World.world?.families?.removeObject(f); } catch { // ignore } } families.Clear(); // Clear lover links on harness actors so AI does not keep odd pairs. for (int i = 0; i < spawned.Count; i++) { Actor a = spawned[i]; try { if (a == null || !a.isAlive()) { continue; } if (a.hasFamily()) { a.setFamily(null); } if (a.hasLover()) { a.setLover(null); } } catch { // ignore } } } private static void TryMoveNear(Actor mover, Actor near) { if (mover == null || near == null) { return; } try { WorldTile tile = near.current_tile; if (tile != null) { mover.current_position = near.current_position; AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) }) ?.Invoke(mover, new object[] { tile }); } } catch { // ignore } } /// /// Park the actor on a distant tile so BehFindLover cannot instantly bond with harness peers. /// private static void TryIsolateFromPeers(Actor actor) { if (actor == null || actor.current_tile == null || World.world?.map_chunk_manager == null) { return; } try { WorldTile best = null; float bestScore = -1f; Vector3 origin = actor.current_position; // Sample a coarse grid of tiles for emptiness + distance. int w = MapBox.width; int h = MapBox.height; int step = Math.Max(8, Math.Min(w, h) / 16); for (int x = 2; x < w - 2; x += step) { for (int y = 2; y < h - 2; y += step) { WorldTile tile = null; try { tile = World.world.GetTile(x, y); } catch { tile = null; } if (tile == null) { continue; } float dist = (tile.posV3 - origin).sqrMagnitude; if (dist < 80f * 80f) { continue; } int nearby = 0; try { foreach (Actor u in Finder.getUnitsFromChunk(tile, 2)) { if (u != null && u.isAlive() && u != actor) { nearby++; if (nearby > 0) { break; } } } } catch { nearby = 99; } if (nearby > 0) { continue; } float score = dist; if (score > bestScore) { bestScore = score; best = tile; } } } if (best != null) { actor.current_position = best.posV3; AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) }) ?.Invoke(actor, new object[] { best }); } } catch { // ignore } } private static Actor SpawnHumanNear(Actor near) { try { WorldTile tile = near != null ? near.current_tile : null; if (tile == null) { Actor anchor = WorldActivityScanner.FindNearestAliveUnit( near != null ? near.current_position : Vector3.zero, 500f); tile = anchor != null ? anchor.current_tile : null; } if (tile == null) { return null; } Actor spawned = World.world.units.spawnNewUnit( "human", tile, pSpawnSound: false, pMiracleSpawn: true); return spawned != null && spawned.isAlive() ? spawned : null; } catch { return null; } } private static void AgeOutOfSpawnWindow(Actor actor) { if (actor?.data == null) { return; } try { double now = World.world != null ? World.world.getCurWorldTime() : 100; actor.data.created_time = now - 20.0; } catch { // ignore } } }