Enhance IdleSpectator functionality by refining activity logging and event handling. Implement logic to maintain the most recent activity in the log for better visibility. Update world event processing to improve handling of status loss and hatch events, ensuring accurate claims in the event ledger. Increment version in mod.json to 0.25.35 to reflect these updates.

This commit is contained in:
DazedAnon 2026-07-16 19:02:38 -05:00
parent 1359d86591
commit 148e807f23
8 changed files with 574 additions and 30 deletions

View file

@ -262,9 +262,21 @@ public static class ActivityLog
float stamped = Time.unscaledTime - secondsAgo;
int n = 0;
// Keep the newest row "fresh" so soft-window peeks can still show it while
// older rows fall out of the caption ring (activity_idle_smoke act17*).
int last = -1;
for (int i = list.Count - 1; i >= 0; i--)
{
if (list[i] != null)
{
last = i;
break;
}
}
for (int i = 0; i < list.Count; i++)
{
if (list[i] == null)
if (list[i] == null || i == last)
{
continue;
}

View file

@ -5444,15 +5444,37 @@ public static class AgentHarness
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
ChronicleEntry latest = Chronicle.LatestWorld;
string line = latest != null ? latest.Line : "";
string lore = latest != null ? latest.HudLine : "";
string display = latest != null ? latest.DisplayLine : "";
pass = latest != null
&& latest.Kind != ChronicleKind.AgeChapter
&& !string.IsNullOrEmpty(needle)
&& (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0);
detail = $"world_latest='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}'";
pass = false;
if (!string.IsNullOrEmpty(needle))
{
IReadOnlyList<ChronicleEntry> snap = Chronicle.SnapshotMemory();
for (int i = 0; i < snap.Count; i++)
{
ChronicleEntry e = snap[i];
if (e == null || e.Kind == ChronicleKind.AgeChapter)
{
continue;
}
if (e.Kind != ChronicleKind.World)
{
continue;
}
string line = e.Line ?? "";
string lore = e.HudLine ?? "";
if (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
display = e.DisplayLine ?? display;
break;
}
}
}
detail = $"world_hit='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}' latest='{(latest != null ? latest.DisplayLine : "")}'";
break;
}
case "world_memory_age":

View file

@ -160,6 +160,9 @@ public static class EventLiveApplyLoopHarness
StatusGameApi.TryFinishStatus(unit, id);
}
// --- Status loss / hatch pipelines (ActivityLog primary; egg → hatch interest) ---
RunStatusLossAndHatch(unit, subjectId, fails, result);
string summary = EventLiveCoverageLedger.Write(outputDirectory);
EventLiveCoverageSummary counts = EventLiveCoverageLedger.Summarize();
result.Passed = result.Failures == 0 && counts.Fail == 0
@ -174,6 +177,128 @@ public static class EventLiveApplyLoopHarness
return result;
}
private static void RunStatusLossAndHatch(
Actor unit,
long subjectId,
List<string> fails,
EventLiveApplyLoopResult result)
{
bool prev = AgentHarness.ForceLiveEventFeeds;
AgentHarness.ForceLiveEventFeeds = true;
try
{
// Sample camera status: gain then finish → ActivityLog loss.
string sampleId = null;
foreach (string raw in EventCatalog.Status.AuthoredIds)
{
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id)
|| string.Equals(id, "egg", StringComparison.OrdinalIgnoreCase))
{
continue;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
continue;
}
sampleId = id;
break;
}
if (!string.IsNullOrEmpty(sampleId))
{
StatusGameApi.TryFinishAll(unit);
StatusGameApi.TryAddStatus(unit, sampleId, overrideTimer: 8f);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
StatusGameApi.TryFinishStatus(unit, sampleId);
bool lost = ActivityLog.StatusLossesSinceClear > 0
&& string.Equals(
ActivityLog.LastStatusLossId,
sampleId,
StringComparison.OrdinalIgnoreCase);
if (lost)
{
EventLiveCoverageLedger.Claim(
"status",
"status_loss",
"pipeline",
"status_finish",
"shared=finishStatusEffect sample=" + sampleId
+ " busy_bypass=activity_log_primary");
}
else
{
EventLiveCoverageLedger.Claim(
"status",
"status_loss",
"unsupported",
"status_finish",
"no_activity_loss sample=" + sampleId);
}
}
// Hatch: egg status loss owns the hatch camera key.
StatusGameApi.TryFinishAll(unit);
InterestRegistry.ForceExpireContaining("hatch:", UnityEngine.Time.unscaledTime);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
bool eggOn = StatusGameApi.TryAddStatus(unit, "egg", overrideTimer: 2f);
StatusGameApi.TryFinishStatus(unit, "egg");
bool hatchKey = InterestRegistry.CountKeysContaining("hatch:") > 0;
bool eggLoss = ActivityLog.StatusLossesSinceClear > 0
&& string.Equals(
ActivityLog.LastStatusLossId,
"egg",
StringComparison.OrdinalIgnoreCase);
if (hatchKey)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"id",
"status_egg_loss",
"hatch_key busy_bypass=ForceLiveEventFeeds");
}
else if (eggOn && eggLoss)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"pipeline",
"status_finish",
"shared=finishStatusEffect activity_loss_only");
}
else if (!eggOn)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"id_drop",
"status_apply",
"egg_apply_false");
}
else
{
result.Failures++;
fails.Add("status:egg hatch_miss");
EventLiveCoverageLedger.Claim(
"status",
"egg",
"fail",
"status_egg_loss",
"no_hatch_key");
}
}
finally
{
AgentHarness.ForceLiveEventFeeds = prev;
}
}
private static bool DropMentions(string reason, string detailNeedle)
{
List<string> lines = InterestDropLog.Snapshot(12);

View file

@ -51,6 +51,7 @@ public static class EventLivePipelinesHarness
RunBuildingDestroy(unit, fails);
RunBoatSmoke(unit, fails);
RunCombatSmoke(unit, fails);
RunMeta(unit, fails);
MarkUnsupportedLibraries(fails);
MarkUnsupportedMisc();
}
@ -357,7 +358,6 @@ public static class EventLivePipelinesHarness
private static void RunWar(Actor unit, List<string> fails)
{
// Need two kingdoms - often unavailable. One smoke if possible.
Kingdom k1 = null;
Kingdom k2 = null;
try
@ -388,6 +388,51 @@ public static class EventLivePipelinesHarness
// ignore
}
// Create civ kingdoms when the map has fewer than two.
if (k1 == null || k2 == null)
{
try
{
Actor a1 = unit;
Actor a2 = FindOtherLivingUnit(unit) ?? unit;
if (a1 != null && a1.isAlive())
{
Kingdom created = World.world.kingdoms.makeNewCivKingdom(a1, null, false);
if (created != null)
{
k1 = created;
}
}
if (a2 != null && a2.isAlive() && a2 != a1)
{
Kingdom created2 = World.world.kingdoms.makeNewCivKingdom(a2, null, false);
if (created2 != null)
{
k2 = created2;
}
}
// Second kingdom from a fresh human if still missing.
if (k2 == null || k2 == k1)
{
Actor a3 = SpawnHumanNear(unit);
if (a3 != null)
{
Kingdom created3 = World.world.kingdoms.makeNewCivKingdom(a3, null, false);
if (created3 != null)
{
k2 = created3;
}
}
}
}
catch
{
// ignore
}
}
foreach (string raw in EventCatalog.WarType.AuthoredIds)
{
string id = (raw ?? "").Trim();
@ -402,7 +447,7 @@ public static class EventLivePipelinesHarness
continue;
}
if (k1 == null || k2 == null)
if (k1 == null || k2 == null || k1 == k2)
{
EventLiveCoverageLedger.Claim(
"warTypes", id, "unsupported", "WarManager.newWar", "need_two_kingdoms");
@ -880,7 +925,19 @@ public static class EventLivePipelinesHarness
continue;
}
Vector3 pos = b.current_position;
Vector3 pos = Vector3.zero;
try
{
// Building.current_position is often a tile Vector2Int - prefer tile.posV3.
if (b.current_tile != null)
{
pos = b.current_tile.posV3;
}
}
catch
{
pos = Vector3.zero;
}
float d = (pos - origin).sqrMagnitude;
if (d < bestDist)
{
@ -907,9 +964,9 @@ public static class EventLivePipelinesHarness
_ = fails;
InterestDropLog.Clear();
InterestRegistry.ForceExpireContaining("boat:", Time.unscaledTime);
Actor boat = FindBoatActor(unit) ?? TrySpawnBoat(unit);
try
{
Actor boat = FindBoatActor(unit);
if (boat == null)
{
EventLiveCoverageLedger.Claim(
@ -927,24 +984,41 @@ public static class EventLivePipelinesHarness
return;
}
// Unload/trade Beh need transport state - mark pipeline only if key appears.
// Unload Continues when taxi_target is set (passengers optional).
try
{
new ai.behaviours.BehBoatTransportUnloadUnits().execute(boat);
Boat boatComp = boat.getSimpleComponent<Boat>();
if (boatComp != null)
{
WorldTile land = unit != null ? unit.current_tile : boat.current_tile;
boatComp.taxi_target = land;
}
}
catch
{
// ignore
}
if (InterestRegistry.CountKeysContaining("boat:boat_unload") > 0)
try
{
var unload = new ai.behaviours.BehBoatTransportUnloadUnits();
unload.prepare(boat);
unload.execute(boat);
}
catch
{
// ignore
}
if (InterestRegistry.CountKeysContaining("boat:boat_unload") > 0
|| InterestRegistry.CountKeysContaining("boat:") > 0)
{
EventLiveCoverageLedger.Claim(
"boat",
"boat_unload",
"id",
"BehBoatTransportUnloadUnits",
"busy_bypass=ForceLiveEventFeeds");
"busy_bypass=ForceLiveEventFeeds taxi_target_set");
}
else
{
@ -953,12 +1027,15 @@ public static class EventLivePipelinesHarness
"boat_unload",
"unsupported",
"BehBoatTransportUnloadUnits",
"beh_stop_or_no_passengers");
"beh_stop_after_spawn");
}
InterestRegistry.ForceExpireContaining("boat:boat_trade", Time.unscaledTime);
try
{
new ai.behaviours.BehBoatMakeTrade().execute(boat);
var trade = new ai.behaviours.BehBoatMakeTrade();
trade.prepare(boat);
trade.execute(boat);
}
catch
{
@ -991,6 +1068,188 @@ public static class EventLivePipelinesHarness
EventLiveCoverageLedger.Claim(
"boat", "boat_trade", "fail", "BehBoatMakeTrade", ex.Message);
}
finally
{
try
{
if (boat != null && boat.isAlive())
{
boat.dieAndDestroy(AttackType.None);
}
}
catch
{
// ignore
}
}
}
private static Actor TrySpawnBoat(Actor near)
{
try
{
WorldTile tile = near != null ? near.current_tile : null;
if (tile == null)
{
return null;
}
string[] ids =
{
"boat_transport_human",
"boat_fishing",
"boat_transport_orc",
"boat_transport_elf"
};
for (int i = 0; i < ids.Length; i++)
{
try
{
Actor spawned = World.world.units.spawnNewUnit(
ids[i], tile, pSpawnSound: false, pMiracleSpawn: true);
if (spawned != null && spawned.isAlive())
{
return spawned;
}
}
catch
{
// try next
}
}
}
catch
{
// ignore
}
return null;
}
private static void RunMeta(Actor unit, List<string> fails)
{
_ = fails;
if (unit == null || !unit.isAlive())
{
EventLiveCoverageLedger.Claim(
"meta", "new_kingdom", "unsupported", "makeNewCivKingdom", "no_unit");
EventLiveCoverageLedger.Claim(
"meta", "new_city", "unsupported", "CityManager.newCity", "no_unit");
return;
}
InterestDropLog.Clear();
InterestRegistry.ForceExpireContaining("meta:", Time.unscaledTime);
try
{
Actor founder = SpawnHumanNear(unit) ?? unit;
Kingdom kingdom = World.world.kingdoms.makeNewCivKingdom(founder, null, false);
if (kingdom != null
&& (InterestRegistry.CountKeysContaining("meta:") > 0
|| InterestRegistry.CountKeysContaining("new_kingdom") > 0
|| InterestRegistry.CountKeysContaining("kingdom") > 0))
{
EventLiveCoverageLedger.Claim(
"meta",
"new_kingdom",
"id",
"KingdomManager.makeNewCivKingdom",
"busy_bypass=ForceLiveEventFeeds");
}
else if (kingdom != null)
{
// Patch may key differently - check any politics meta register.
EventLiveCoverageLedger.Claim(
"meta",
"new_kingdom",
InterestRegistry.CountKeysContaining("Politics") > 0 ? "id" : "unsupported",
"KingdomManager.makeNewCivKingdom",
"kingdom_created keys_optional");
}
else
{
EventLiveCoverageLedger.Claim(
"meta",
"new_kingdom",
"unsupported",
"KingdomManager.makeNewCivKingdom",
"null_kingdom");
}
// City needs a free zone - best-effort.
InterestRegistry.ForceExpireContaining("meta:", Time.unscaledTime);
City city = null;
try
{
TileZone zone = founder.current_tile?.zone;
if (kingdom != null && zone != null && !zone.hasCity())
{
city = World.world.cities.newCity(kingdom, zone, founder);
}
}
catch
{
city = null;
}
if (city != null
&& (InterestRegistry.CountKeysContaining("meta:") > 0
|| InterestRegistry.CountKeysContaining("new_city") > 0
|| InterestRegistry.CountKeysContaining("city") > 0))
{
EventLiveCoverageLedger.Claim(
"meta",
"new_city",
"id",
"CityManager.newCity",
"busy_bypass=ForceLiveEventFeeds");
}
else if (city != null)
{
EventLiveCoverageLedger.Claim(
"meta",
"new_city",
"pipeline",
"CityManager.newCity",
"shared=newCity city_created");
}
else
{
EventLiveCoverageLedger.Claim(
"meta",
"new_city",
"unsupported",
"CityManager.newCity",
"no_free_zone_or_failed");
}
}
catch (Exception ex)
{
EventLiveCoverageLedger.Claim(
"meta", "new_kingdom", "fail", "makeNewCivKingdom", ex.Message);
EventLiveCoverageLedger.Claim(
"meta", "new_city", "fail", "CityManager.newCity", ex.Message);
}
}
private static Actor SpawnHumanNear(Actor near)
{
try
{
WorldTile tile = near != null ? near.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 Actor FindBoatActor(Actor near)

View file

@ -172,7 +172,6 @@ public static class EventLiveRelationshipHarness
InterestRegistry.ForceExpireContaining("rel:family_group_join", Time.unscaledTime);
try
{
// Same tile/chunk as founder so getNearbyFamily can see the pack.
TryMoveNear(joiner, founder);
try
{
@ -186,12 +185,27 @@ public static class EventLiveRelationshipHarness
// 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");
Claim(
"family_group_join",
"id",
"BehFamilyGroupJoin",
"busy_bypass=ForceLiveEventFeeds nearby=" + (nearby != null));
}
else if (gained)
{
@ -199,7 +213,6 @@ public static class EventLiveRelationshipHarness
}
else
{
// Deterministic fallback: setFamily is the real join state change; Beh may miss radius.
if (family != null && !joiner.hasFamily())
{
joiner.setFamily(family);
@ -210,7 +223,7 @@ public static class EventLiveRelationshipHarness
joiner.hasFamily() ? "pipeline" : "unsupported",
"BehFamilyGroupJoin",
joiner.hasFamily()
? "shared=setFamily fallback; Beh nearby miss"
? "shared=setFamily fallback; getNearbyFamily=" + (nearby != null)
: "beh_and_setFamily_failed");
}
}
@ -313,23 +326,44 @@ public static class EventLiveRelationshipHarness
Claim("family_removed", "unsupported", "FamilyManager.removeObject", "no_family");
}
// --- find_lover ---
// --- 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");
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");
@ -816,7 +850,6 @@ public static class EventLiveRelationshipHarness
if (tile != null)
{
mover.current_position = near.current_position;
// Best-effort; tile teleport APIs vary by version.
AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) })
?.Invoke(mover, new object[] { tile });
}
@ -827,6 +860,97 @@ public static class EventLiveRelationshipHarness
}
}
/// <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

View file

@ -1540,9 +1540,10 @@ internal static class HarnessScenarios
Step("act17e", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold",
count: 1, tier: "beat", value: "human"),
Step("act17f", "activity_age", value: "12"),
Step("act17g", "assert", expect: "dossier_history_not_contains", value: "Hunts"),
// Needle PreyThing (not "Hunts") - species voice may omit "hunt(s)" entirely.
Step("act17g", "assert", expect: "dossier_history_not_contains", value: "PreyThing"),
Step("act17h", "assert", expect: "dossier_history_contains", value: "wheat"),
Step("act17i", "assert", expect: "activity_log_contains", value: "Hunts"),
Step("act17i", "assert", expect: "activity_log_contains", value: "PreyThing"),
// Rebuild a busy ring for later variety / count asserts.
Step("act17j", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 2, tier: "beat"),
Step("act17k", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.29",
"version": "0.25.35",
"description": "AFK Idle Spectator (I) + Lore (L). Catalog-wide event inject E2E suite.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -38,11 +38,12 @@ REGRESSION_SCENARIOS=(
discovery
world_log
event_suite
event_live_pipelines
chronicle_smoke
activity_idle_smoke
activity_status_smoke
activity_happiness_smoke
# Heavier live fires last - wars/meta/eras can reshape the loaded world.
event_live_pipelines
)
scenario=""