worldbox-observer-mod/IdleSpectator/EventLiveRelationshipHarness.cs

998 lines
33 KiB
C#

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;
}
/// <summary>
/// Phase 2: vanilla relationship fires with ForceLiveEventFeeds.
/// Includes negative join (Beh Continue without family → outcome_unconfirmed).
/// Always detaches members before <c>FamilyManager.removeObject</c> so leftover
/// families cannot NRE vanilla <c>Family.isFull</c> / <c>getNearbyFamily</c>.
/// </summary>
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<string>(16);
var spawned = new List<Actor>(32);
var families = new List<Family>(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
{
// Park far from founder family if possible (still same world).
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<string> 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<string> 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<Actor> spawned)
{
if (actor != null)
{
spawned.Add(actor);
}
return actor;
}
private static void TrackFamily(Family family, List<Family> 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<Actor> 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;
}
/// <summary>
/// Detach every living member. Prevents zombie family refs that NRE
/// <c>Family.isFull</c> via null <c>getActorAsset</c>.
/// </summary>
private static void DetachAllMembers(Family family)
{
DetachUnitsStillHoldingFamily(family);
}
private static void DetachUnitsStillHoldingFamily(Family family)
{
if (family == null)
{
return;
}
var toDetach = new List<Actor>(16);
try
{
List<Actor> 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<Actor> spawned, List<Family> 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
}
}
/// <summary>
/// Park the actor on a distant tile so BehFindLover cannot instantly bond with harness peers.
/// </summary>
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
}
}
}