- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy - Implement narrative presentation coverage tracking for better event visibility - Update harness scenarios to include new narrative ingestion and persistence tests - Refactor event emission to include narrative development IDs and presentations - Expand InterestCandidate to support narrative presentation models and roles
1001 lines
33 KiB
C#
1001 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
|
|
{
|
|
// 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<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
|
|
}
|
|
}
|
|
}
|