This commit is contained in:
DazedAnon 2026-07-15 22:16:24 -05:00
parent eaa431cc3d
commit 020d73fa9c
28 changed files with 2331 additions and 207 deletions

View file

@ -299,6 +299,24 @@ public static class ActivityAssetCatalog
return EnumerateLibraryIds("buildings");
}
public static List<string> EnumerateLiveSpellIds() => EnumerateLibraryIds("spells");
public static List<string> EnumerateLivePowerIds() => EnumerateLibraryIds("powers");
public static List<string> EnumerateLiveDecisionIds() => EnumerateLibraryIds("decisions_library");
public static List<string> EnumerateLiveItemIds() => EnumerateLibraryIds("items");
public static List<string> EnumerateLiveSubspeciesTraitIds() => EnumerateLibraryIds("subspecies_traits");
public static List<string> EnumerateLiveGeneIds() => EnumerateLibraryIds("gene_library");
public static List<string> EnumerateLivePhenotypeIds() => EnumerateLibraryIds("phenotype_library");
public static List<string> EnumerateLiveWorldLawIds() => EnumerateLibraryIds("world_laws_library");
public static List<string> EnumerateLiveBiomeIds() => EnumerateLibraryIds("biome_library");
private static List<string> EnumerateLibraryIds(string assetManagerField)
{
var ids = new List<string>();

View file

@ -0,0 +1,368 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
namespace IdleSpectator;
/// <summary>
/// Live-game relation resolver. Labels that claim a second party must resolve through here.
/// </summary>
public static class ActorRelation
{
public static Actor GetLover(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
if (!actor.hasLover())
{
return null;
}
}
catch
{
// fall through to field/property
}
try
{
Actor lover = null;
try
{
lover = actor.lover;
}
catch
{
lover = null;
}
if (lover == null)
{
lover = ReadActorMember(actor, "lover", "get_lover", "getLover");
}
if (lover != null && lover.isAlive() && lover != actor)
{
return lover;
}
}
catch
{
// ignore
}
return null;
}
public static bool TryGetLover(Actor actor, out Actor lover)
{
lover = GetLover(actor);
return lover != null;
}
public static Actor GetBestFriend(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
long id = 0;
object data = actor.data;
if (data != null)
{
object raw = data.GetType().GetProperty("best_friend_id")?.GetValue(data, null)
?? data.GetType().GetField("best_friend_id")?.GetValue(data);
if (raw is long l)
{
id = l;
}
else if (raw is int i)
{
id = i;
}
}
if (id == 0)
{
return null;
}
return FindUnitById(id);
}
catch
{
return null;
}
}
/// <summary>First living peer in the same family (not self).</summary>
public static Actor FirstFamilyPeer(Actor actor)
{
foreach (Actor peer in EnumerateFamilyMembers(actor, max: 8))
{
if (peer != null && peer != actor && peer.isAlive())
{
return peer;
}
}
return null;
}
public static IEnumerable<Actor> EnumerateFamilyMembers(Actor actor, int max = 12)
{
if (actor == null || max <= 0)
{
yield break;
}
object family = ReadMember(actor, "family", "get_family", "getFamily");
if (family == null)
{
yield break;
}
int yielded = 0;
foreach (Actor member in EnumerateActorsFromMeta(family))
{
if (member == null || !member.isAlive())
{
continue;
}
yield return member;
yielded++;
if (yielded >= max)
{
yield break;
}
}
}
public static IEnumerable<Actor> EnumerateChildren(Actor actor, int max = 12)
{
if (actor == null || max <= 0)
{
yield break;
}
IEnumerable children = null;
try
{
MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes);
if (getChildren != null)
{
children = getChildren.Invoke(actor, null) as IEnumerable;
}
}
catch
{
children = null;
}
if (children == null)
{
yield break;
}
int yielded = 0;
foreach (object raw in children)
{
Actor child = raw as Actor ?? ResolveActor(raw);
if (child == null || !child.isAlive())
{
continue;
}
yield return child;
yielded++;
if (yielded >= max)
{
yield break;
}
}
}
/// <summary>
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
/// </summary>
public static Actor ResolvePackRelated(Actor subject)
{
if (subject == null)
{
return null;
}
Actor lover = GetLover(subject);
if (lover != null)
{
return lover;
}
Actor peer = FirstFamilyPeer(subject);
if (peer != null)
{
return peer;
}
foreach (Actor child in EnumerateChildren(subject, max: 1))
{
return child;
}
return null;
}
public static Actor ResolveActor(object raw)
{
if (raw == null)
{
return null;
}
if (raw is Actor actor)
{
return actor;
}
try
{
if (raw is BaseSimObject sim && sim.isActor())
{
return sim.a;
}
}
catch
{
// fall through
}
return ReadActorMember(raw, "actor", "Actor", "a", "getActor", "GetActor");
}
private static IEnumerable<Actor> EnumerateActorsFromMeta(object meta)
{
if (meta == null)
{
yield break;
}
// Common: units / members / getUnits / getSimpleList
object list = ReadMember(meta, "units", "members", "getUnits", "getSimpleList", "getList");
if (list is IEnumerable enumerable)
{
foreach (object raw in enumerable)
{
Actor a = ResolveActor(raw);
if (a != null)
{
yield return a;
}
}
yield break;
}
Actor founder = ReadActorMember(meta, "founder", "creator", "leader", "getFounder");
if (founder != null)
{
yield return founder;
}
}
private static Actor FindUnitById(long id)
{
if (id == 0 || World.world?.units == null)
{
return null;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null)
{
continue;
}
try
{
if (unit.getID() == id && unit.isAlive())
{
return unit;
}
}
catch
{
// skip
}
}
}
catch
{
// ignore
}
return null;
}
private static Actor ReadActorMember(object target, params string[] names)
{
object raw = ReadMember(target, names);
return raw as Actor;
}
private static object ReadMember(object target, params string[] names)
{
if (target == null || names == null)
{
return null;
}
Type type = target as Type ?? target.GetType();
object instance = target is Type ? null : target;
foreach (string name in names)
{
if (string.IsNullOrEmpty(name))
{
continue;
}
try
{
PropertyInfo prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop != null)
{
return prop.GetValue(instance, null);
}
FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field != null)
{
return field.GetValue(instance);
}
MethodInfo method = type.GetMethod(name, Type.EmptyTypes);
if (method != null)
{
return method.Invoke(instance, null);
}
}
catch
{
// try next
}
}
return null;
}
}

View file

@ -2521,7 +2521,7 @@ public static class AgentHarness
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
_cmdOk++;
Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}");
}
@ -2570,13 +2570,43 @@ public static class AgentHarness
}
entry = RelationshipEventCatalog.GetOrFallback(id);
key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit);
label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "");
Actor related = ActorRelation.ResolvePackRelated(unit)
?? WorldActivityScanner.FindNearestAliveUnit(unit.current_position, 40f);
if (related == unit)
{
related = null;
}
key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit) + ":"
+ EventFeedUtil.SafeId(related);
label = entry.MakeLabel(
EventFeedUtil.SafeName(unit),
EventFeedUtil.SafeName(related));
owns = entry.OwnsCamera;
strength = entry.EventStrength;
category = entry.Category;
source = "relationship";
break;
InterestCandidate relReg = EventFeedUtil.Register(
key,
source,
category,
label,
id,
strength,
owns,
unit.current_position,
unit,
related: related);
if (relReg == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "register_failed domain=relationship id=" + id);
return;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"domain=relationship id={id} key={key}");
return;
case "plot":
if (string.IsNullOrEmpty(id))
{
@ -2598,25 +2628,38 @@ public static class AgentHarness
}
entry = EraInterestCatalog.GetOrFallback(id);
if (unit == null)
{
unit = EventFeedUtil.AnyAliveUnit();
}
if (unit == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit for era feed");
return;
}
key = "era:" + entry.Id;
label = entry.MakeLabel("", "");
owns = entry.OwnsCamera;
strength = entry.EventStrength;
category = entry.Category;
source = "era";
break;
Vector3 eraPos = unit != null
? unit.current_position
: (Camera.main != null
? new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, 0f)
: new Vector3(1f, 1f, 0f));
InterestCandidate eraReg = EventFeedUtil.Register(
key,
source,
category,
label,
id,
strength,
owns,
eraPos,
follow: unit,
locationOnly: unit == null);
if (eraReg == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "register_failed domain=era id=" + id);
return;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"domain=era id={id} key={key}");
return;
case "book":
if (string.IsNullOrEmpty(id))
{
@ -2675,6 +2718,115 @@ public static class AgentHarness
strength = 70f;
category = "Spectacle";
source = "building";
break;
case "spell":
if (string.IsNullOrEmpty(id))
{
id = "summon_lightning";
}
{
DiscreteEventEntry spellEntry = SpellInterestCatalog.GetOrFallback(id);
key = "spell:" + spellEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = spellEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
owns = spellEntry.OwnsCamera;
strength = spellEntry.EventStrength;
category = spellEntry.Category;
source = "spell";
id = spellEntry.Id;
}
break;
case "item":
if (string.IsNullOrEmpty(id))
{
id = "sword";
}
{
DiscreteEventEntry itemEntry = ItemInterestCatalog.GetOrFallback(id);
key = "item:" + itemEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = itemEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
owns = itemEntry.OwnsCamera;
strength = itemEntry.EventStrength;
category = itemEntry.Category;
source = "item";
id = itemEntry.Id;
}
break;
case "decision":
if (string.IsNullOrEmpty(id))
{
id = "declare_war";
}
{
DiscreteEventEntry decEntry = DecisionInterestCatalog.GetOrFallback(id);
key = "decision:" + decEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = decEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
owns = decEntry.OwnsCamera;
strength = decEntry.EventStrength;
category = decEntry.Category;
source = "decision";
id = decEntry.Id;
}
break;
case "power":
if (string.IsNullOrEmpty(id))
{
id = "lightning";
}
{
DiscreteEventEntry powEntry = PowerInterestCatalog.GetOrFallback(id);
key = "power:" + powEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = powEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
owns = powEntry.OwnsCamera;
strength = powEntry.EventStrength;
category = powEntry.Category;
source = "power";
id = powEntry.Id;
}
break;
case "subspecies_trait":
case "subspecies":
if (string.IsNullOrEmpty(id))
{
id = "biome_trait";
}
{
DiscreteEventEntry stEntry = SubspeciesTraitInterestCatalog.GetOrFallback(id);
key = "subspecies_trait:" + stEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = stEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
owns = stEntry.OwnsCamera;
strength = stEntry.EventStrength;
category = stEntry.Category;
source = "subspecies_trait";
id = stEntry.Id;
}
break;
case "gene":
if (string.IsNullOrEmpty(id))
{
id = "warfare_1";
}
{
DiscreteEventEntry geneEntry = GeneInterestCatalog.GetOrFallback(id);
key = "gene:" + geneEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = geneEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
owns = geneEntry.OwnsCamera;
strength = geneEntry.EventStrength;
category = geneEntry.Category;
source = "gene";
id = geneEntry.Id;
}
break;
default:
_cmdFail++;
@ -3511,6 +3663,45 @@ public static class AgentHarness
detail = $"needle_absent_in_reason='{needle}' reason='{reason}' hit={reasonHit}";
break;
}
case "reason_owns_focus":
{
// Reason must not name a different live unit than the scene subject/related.
UnitDossier dossier = WatchCaption.Current;
string reason = dossier?.ReasonLine ?? "";
InterestCandidate scene = InterestDirector.CurrentCandidate;
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (string.IsNullOrEmpty(reason))
{
pass = true;
detail = "empty_reason_ok";
break;
}
if (scene == null || focus == null)
{
pass = string.IsNullOrEmpty(reason);
detail = $"scene={(scene != null)} focus={(focus != null)} reason='{reason}'";
break;
}
bool sceneOwnsFocus = scene.FollowUnit == focus
|| scene.RelatedUnit == focus
|| (scene.SubjectId != 0 && scene.SubjectId == EventFeedUtil.SafeId(focus))
|| (scene.RelatedId != 0 && scene.RelatedId == EventFeedUtil.SafeId(focus));
if (!sceneOwnsFocus)
{
pass = false;
detail = "reason_present_but_scene_does_not_own_focus reason='" + reason + "'";
break;
}
string stranger = FindStrangerNamedInReason(reason, scene);
pass = string.IsNullOrEmpty(stranger);
detail = pass
? $"owned reason='{reason}'"
: $"stranger_in_reason='{stranger}' reason='{reason}'";
break;
}
case "dossier_task_refresh":
{
// Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI.
@ -5905,6 +6096,64 @@ public static class AgentHarness
return actor.asset != null ? actor.asset.id : "unit";
}
/// <summary>
/// If the reason line names a living unit that is not the scene subject/related, return that name.
/// </summary>
private static string FindStrangerNamedInReason(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
{
return "";
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
{
continue;
}
string name = SafeName(unit);
if (string.IsNullOrEmpty(name) || name.Length < 3)
{
continue;
}
if (!string.IsNullOrEmpty(subjectName)
&& name.Equals(subjectName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrEmpty(relatedName)
&& name.Equals(relatedName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (reason.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
{
return name;
}
}
}
catch
{
// ignore
}
return "";
}
private static float EventStrengthFromTierHint(string raw, float fallback)
{

View file

@ -21,11 +21,7 @@ public static class BookInterestFeed
Actor follow = subject;
if (follow == null || !follow.isAlive())
{
follow = EventFeedUtil.AnyAliveUnit();
}
if (follow == null)
{
// Book without a living author: skip rather than invent a stranger.
return;
}

View file

@ -62,10 +62,31 @@ public static class BuildingEventPatches
}
Actor near = pos != Vector3.zero
? EventFeedUtil.NearestUnit(pos) ?? EventFeedUtil.AnyAliveUnit()
: EventFeedUtil.AnyAliveUnit();
? EventFeedUtil.NearestUnit(pos, 48f)
: null;
if (near == null)
{
// Location-only building fall - no stranger subject.
if (pos == Vector3.zero)
{
return;
}
string locLabel = string.IsNullOrEmpty(buildingName)
? "Building destroyed"
: "Building falls: " + buildingName;
string locKey = "building:destroy:" + buildingName + ":loc";
EventFeedUtil.Register(
locKey,
"building",
"Spectacle",
locLabel,
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
88f,
InterestOwnership.Signal,
pos,
follow: null,
locationOnly: true);
return;
}

View file

@ -33,6 +33,7 @@ public static class CameraDirector
}
string tip = FormatWatchTip(interest);
// LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
LogService.LogInfo($"[IdleSpectator] {tip}");
@ -42,17 +43,13 @@ public static class CameraDirector
? interest.FollowUnit
: null;
// Never attach a random nearby unit to a "new species" tip - that caused ghost focuses
// (e.g. tip says crab, camera follows a cat).
// Species discovery: only attach a species-matched unit, never a random stranger.
if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
&& interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
else if (follow == null)
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f);
}
// Location-only civic: pan only. Do not invent a stranger FollowUnit.
if (follow != null && follow.isAlive())
{
@ -74,7 +71,7 @@ public static class CameraDirector
return;
}
// No unit available: pan only if we are not already following someone.
// No owned unit: pan only if we are not already following someone.
// Never clear an existing focus unit here - that flashes the power bar.
if (!MoveCamera.hasFocusUnit())
{

View file

@ -140,6 +140,75 @@ public static class DomainEventHarness
TraitInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "traits", ActivityAssetCatalog.EnumerateLiveTraitIds(), traits, TraitInterestCatalog.GetOrFallback);
CatalogCoverageDiff spells = CatalogCoverageAudit.Diff(
"spells",
ActivityAssetCatalog.EnumerateLiveSpellIds(),
SpellInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "spells", ActivityAssetCatalog.EnumerateLiveSpellIds(), spells, SpellInterestCatalog.GetOrFallback);
CatalogCoverageDiff powers = CatalogCoverageAudit.Diff(
"powers",
ActivityAssetCatalog.EnumerateLivePowerIds(),
PowerInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "powers", ActivityAssetCatalog.EnumerateLivePowerIds(), powers, PowerInterestCatalog.GetOrFallback);
CatalogCoverageDiff decisions = CatalogCoverageAudit.Diff(
"decisions",
ActivityAssetCatalog.EnumerateLiveDecisionIds(),
DecisionInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "decisions", ActivityAssetCatalog.EnumerateLiveDecisionIds(), decisions, DecisionInterestCatalog.GetOrFallback);
CatalogCoverageDiff items = CatalogCoverageAudit.Diff(
"items",
ActivityAssetCatalog.EnumerateLiveItemIds(),
ItemInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "items", ActivityAssetCatalog.EnumerateLiveItemIds(), items, ItemInterestCatalog.GetOrFallback);
CatalogCoverageDiff subspTraits = CatalogCoverageAudit.Diff(
"subspecies_traits",
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(),
SubspeciesTraitInterestCatalog.AuthoredIds);
AppendLibraryDomain(
tsv,
"subspecies_traits",
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(),
subspTraits,
SubspeciesTraitInterestCatalog.GetOrFallback);
CatalogCoverageDiff genes = CatalogCoverageAudit.Diff(
"genes",
ActivityAssetCatalog.EnumerateLiveGeneIds(),
GeneInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "genes", ActivityAssetCatalog.EnumerateLiveGeneIds(), genes, GeneInterestCatalog.GetOrFallback);
CatalogCoverageDiff phenotypes = CatalogCoverageAudit.Diff(
"phenotypes",
ActivityAssetCatalog.EnumerateLivePhenotypeIds(),
PhenotypeInterestCatalog.AuthoredIds);
AppendLibraryDomain(
tsv,
"phenotypes",
ActivityAssetCatalog.EnumerateLivePhenotypeIds(),
phenotypes,
PhenotypeInterestCatalog.GetOrFallback);
CatalogCoverageDiff worldLaws = CatalogCoverageAudit.Diff(
"world_laws",
ActivityAssetCatalog.EnumerateLiveWorldLawIds(),
WorldLawInterestCatalog.AuthoredIds);
AppendLibraryDomain(
tsv,
"world_laws",
ActivityAssetCatalog.EnumerateLiveWorldLawIds(),
worldLaws,
WorldLawInterestCatalog.GetOrFallback);
CatalogCoverageDiff biomes = CatalogCoverageAudit.Diff(
"biomes",
ActivityAssetCatalog.EnumerateLiveBiomeIds(),
BiomeInterestCatalog.AuthoredIds);
AppendLibraryDomain(tsv, "biomes", ActivityAssetCatalog.EnumerateLiveBiomeIds(), biomes, BiomeInterestCatalog.GetOrFallback);
int relMissing = 0;
int relTotal = 0;
foreach (string id in RelationshipEventCatalog.AuthoredIds)
@ -162,9 +231,15 @@ public static class DomainEventHarness
CatalogCoverageAudit.WriteReport(outputDirectory, "domain_event_audit.tsv", tsv.ToString());
bool passed = plots.Passed && eras.Passed && books.Passed && traits.Passed
&& spells.Passed && powers.Passed && decisions.Passed && items.Passed
&& subspTraits.Passed && genes.Passed && phenotypes.Passed
&& worldLaws.Passed && biomes.Passed
&& relMissing == 0 && relTotal > 0;
string detail =
$"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; relationship: authored={relTotal} missing={relMissing}";
$"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; "
+ $"{spells.Detail}; {powers.Detail}; {decisions.Detail}; {items.Detail}; "
+ $"{subspTraits.Detail}; {genes.Detail}; {phenotypes.Detail}; "
+ $"{worldLaws.Detail}; {biomes.Detail}; relationship: authored={relTotal} missing={relMissing}";
return new DomainEventAuditResult { Passed = passed, Detail = detail };
}

View file

@ -0,0 +1,310 @@
using System;
using HarmonyLib;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Harmony hooks for Phase 2 deferred libraries (items, spells, decisions, subspecies).</summary>
[HarmonyPatch]
public static class DeferredEventPatches
{
[HarmonyPatch(typeof(ItemManager), nameof(ItemManager.generateItem))]
[HarmonyPostfix]
public static void PostfixGenerateItem(object __result, object[] __args)
{
if (AgentHarness.Busy)
{
return;
}
Actor craftsman = null;
if (__args != null)
{
for (int i = 0; i < __args.Length; i++)
{
if (__args[i] is Actor a && a.isAlive())
{
craftsman = a;
break;
}
}
}
string itemId = ReadAssetId(__result) ?? "item";
if (craftsman == null)
{
return;
}
DeferredInterestFeed.EmitItem(itemId, craftsman);
}
[HarmonyPatch(typeof(ItemManager), "newItem")]
[HarmonyPostfix]
public static void PostfixNewItem(object __result)
{
// newItem may lack an actor; skip without a subject rather than invent one.
if (AgentHarness.Busy || __result == null)
{
return;
}
Actor owner = ReadActorMember(__result, "owner", "actor", "creator", "getOwner");
if (owner == null || !owner.isAlive())
{
return;
}
DeferredInterestFeed.EmitItem(ReadAssetId(__result) ?? "item", owner);
}
// Spells: live catalog + domain_feed; cast site varies by game build (no stable Actor.tryToCastSpell).
[HarmonyPatch(typeof(Actor), nameof(Actor.setDecisionCooldown))]
[HarmonyPostfix]
public static void PostfixSetDecisionCooldown(Actor __instance, object[] __args)
{
if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
{
return;
}
object pDecision = __args != null && __args.Length > 0 ? __args[0] : null;
if (pDecision == null)
{
return;
}
string id = ReadAssetId(pDecision) ?? pDecision.ToString();
if (string.IsNullOrEmpty(id))
{
return;
}
DiscreteEventEntry entry = DecisionInterestCatalog.GetOrFallback(id);
// Only emit Signal decisions from this high-frequency hook; Ambient would flood.
if (entry.OwnsCamera != InterestOwnership.Signal)
{
return;
}
DeferredInterestFeed.EmitDecision(id, __instance);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.generatePhenotypeAndShade))]
[HarmonyPostfix]
public static void PostfixPhenotype(Actor __instance)
{
if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
{
return;
}
string id = "phenotype";
try
{
object data = __instance.data;
object ph = data?.GetType().GetField("phenotype")?.GetValue(data)
?? data?.GetType().GetProperty("phenotype")?.GetValue(data, null);
if (ph != null)
{
id = ph.ToString();
}
}
catch
{
// ignore
}
DeferredInterestFeed.EmitPhenotype(id, __instance);
}
[HarmonyPatch(typeof(SubspeciesManager), "addRandomTraitFromBiomeToSubspecies")]
[HarmonyPostfix]
public static void PostfixSubspeciesTraitRandom(object[] __args)
{
object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null;
EmitSubspecies(pSubspecies, "biome_trait");
}
[HarmonyPatch(typeof(SubspeciesManager), "addTraitsFromBiomeToSubspecies")]
[HarmonyPostfix]
public static void PostfixSubspeciesTraits(object[] __args)
{
object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null;
EmitSubspecies(pSubspecies, "biome_traits");
}
private static void EmitSubspecies(object subspecies, string fallbackId)
{
if (AgentHarness.Busy || subspecies == null)
{
return;
}
Actor anchor = ReadActorMember(subspecies, "founder", "creator", "leader", "getFounder");
if (anchor == null || !anchor.isAlive())
{
// Prefer a unit of this subspecies if possible.
anchor = FindUnitForSubspecies(subspecies);
}
if (anchor == null)
{
return;
}
string traitId = fallbackId;
try
{
object last = subspecies.GetType().GetField("last_trait")?.GetValue(subspecies)
?? subspecies.GetType().GetProperty("last_trait")?.GetValue(subspecies, null);
if (last != null)
{
traitId = ReadAssetId(last) ?? last.ToString() ?? fallbackId;
}
}
catch
{
// ignore
}
DeferredInterestFeed.EmitSubspeciesTrait(traitId, anchor);
}
private static Actor FindUnitForSubspecies(object subspecies)
{
if (subspecies == null || World.world?.units == null)
{
return null;
}
string sid = null;
try
{
object id = subspecies.GetType().GetMethod("getID")?.Invoke(subspecies, null)
?? subspecies.GetType().GetProperty("id")?.GetValue(subspecies, null);
sid = id?.ToString();
}
catch
{
sid = null;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
try
{
object sub = unit.GetType().GetProperty("subspecies")?.GetValue(unit, null)
?? unit.GetType().GetField("subspecies")?.GetValue(unit);
if (sub == subspecies)
{
return unit;
}
if (!string.IsNullOrEmpty(sid))
{
object uid = sub?.GetType().GetMethod("getID")?.Invoke(sub, null);
if (uid != null && uid.ToString() == sid)
{
return unit;
}
}
}
catch
{
// skip
}
}
}
catch
{
// ignore
}
return null;
}
private static string ReadAssetId(object obj)
{
if (obj == null)
{
return null;
}
try
{
if (obj is string s)
{
return s;
}
object asset = obj.GetType().GetField("asset")?.GetValue(obj)
?? obj.GetType().GetProperty("asset")?.GetValue(obj, null)
?? obj.GetType().GetMethod("getAsset")?.Invoke(obj, null);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
if (id != null)
{
return id.ToString();
}
}
object direct = obj.GetType().GetField("id")?.GetValue(obj)
?? obj.GetType().GetProperty("id")?.GetValue(obj, null);
return direct?.ToString();
}
catch
{
return null;
}
}
private static Actor ReadActorMember(object target, params string[] names)
{
if (target == null || names == null)
{
return null;
}
Type type = target.GetType();
foreach (string name in names)
{
try
{
var prop = type.GetProperty(name);
if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType))
{
return prop.GetValue(target, null) as Actor;
}
var field = type.GetField(name);
if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType))
{
return field.GetValue(target) as Actor;
}
var method = type.GetMethod(name, Type.EmptyTypes);
if (method != null && typeof(Actor).IsAssignableFrom(method.ReturnType))
{
return method.Invoke(target, null) as Actor;
}
}
catch
{
// try next
}
}
return null;
}
}

View file

@ -0,0 +1,198 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Unit-owned emits for deferred Phase 2 libraries (spells/items/decisions/etc.).</summary>
public static class DeferredInterestFeed
{
public static void EmitSpell(string spellId, Actor caster)
{
EmitFromCatalog(
"spell",
spellId,
caster,
SpellInterestCatalog.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitItem(string itemId, Actor craftsman)
{
EmitFromCatalog(
"item",
itemId,
craftsman,
ItemInterestCatalog.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitDecision(string decisionId, Actor actor)
{
EmitFromCatalog(
"decision",
decisionId,
actor,
DecisionInterestCatalog.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitPower(string powerId, Vector3 position, Actor nearUnit)
{
DiscreteEventEntry entry = PowerInterestCatalog.GetOrFallback(powerId);
if (!entry.CreatesInterest || AgentHarness.Busy)
{
return;
}
if (nearUnit != null && nearUnit.isAlive())
{
EmitFromCatalog("power", powerId, nearUnit, PowerInterestCatalog.GetOrFallback, null, false);
return;
}
if (position == Vector3.zero)
{
return;
}
EventFeedUtil.Register(
"power:" + entry.Id,
"power",
entry.Category,
entry.MakeLabel("", ""),
entry.Id,
entry.EventStrength,
entry.OwnsCamera,
position,
follow: null,
locationOnly: true);
}
public static void EmitSubspeciesTrait(string traitId, Actor anchor)
{
EmitFromCatalog(
"subspecies_trait",
traitId,
anchor,
SubspeciesTraitInterestCatalog.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitGene(string geneId, Actor actor)
{
EmitFromCatalog(
"gene",
geneId,
actor,
GeneInterestCatalog.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitPhenotype(string phenotypeId, Actor actor)
{
EmitFromCatalog(
"phenotype",
phenotypeId,
actor,
PhenotypeInterestCatalog.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitWorldLaw(string lawId, Vector3 position)
{
if (AgentHarness.Busy || position == Vector3.zero)
{
return;
}
DiscreteEventEntry entry = WorldLawInterestCatalog.GetOrFallback(lawId);
if (!entry.CreatesInterest)
{
return;
}
EventFeedUtil.Register(
"worldlaw:" + entry.Id,
"worldlaw",
entry.Category,
entry.MakeLabel("", ""),
entry.Id,
entry.EventStrength,
entry.OwnsCamera,
position,
follow: null,
locationOnly: true);
}
public static void EmitBiome(string biomeId, Vector3 position)
{
if (AgentHarness.Busy || position == Vector3.zero)
{
return;
}
DiscreteEventEntry entry = BiomeInterestCatalog.GetOrFallback(biomeId);
if (!entry.CreatesInterest)
{
return;
}
EventFeedUtil.Register(
"biome:" + entry.Id,
"biome",
entry.Category,
entry.MakeLabel("", ""),
entry.Id,
entry.EventStrength,
entry.OwnsCamera,
position,
follow: null,
locationOnly: true);
}
private static void EmitFromCatalog(
string source,
string assetId,
Actor subject,
System.Func<string, DiscreteEventEntry> lookup,
Actor related,
bool locationOnly)
{
if (AgentHarness.Busy || lookup == null)
{
return;
}
DiscreteEventEntry entry = lookup(assetId);
if (!entry.CreatesInterest)
{
return;
}
Actor follow = subject != null && subject.isAlive() ? subject : null;
if (!locationOnly && follow == null)
{
return;
}
string label = entry.MakeLabel(EventFeedUtil.SafeName(follow), "");
string key = source + ":" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
source,
entry.Category,
label,
entry.Id,
entry.EventStrength,
entry.OwnsCamera,
follow != null ? follow.current_position : Vector3.zero,
follow,
related: related,
locationOnly: locationOnly);
}
}

View file

@ -0,0 +1,391 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Live spells library interest overlay (Signal when cast resolves to a unit).</summary>
public static class SpellInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> SignalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"summon_lightning", "summon_tornado", "cast_curse", "cast_fire",
"cast_blood_rain", "summon_meteor", "teleport"
};
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSpellIds,
"Spectacle",
"Spell: {id} ({a})",
ambientStrength: 48f,
SignalIds,
signalStrength: 88f);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "Spell: {id} ({a})", 55f);
}
}
/// <summary>God powers library - mostly Ambient; Signal for disaster/spectacle ids.</summary>
public static class PowerInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsSpectaclePower(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("meteor")
|| s.Contains("earthquake")
|| s.Contains("tornado")
|| s.Contains("volcano")
|| s.Contains("nuke")
|| s.Contains("bomb")
|| s.Contains("rain_blood")
|| s.Contains("hell")
|| s.Contains("lightning")
|| s.Contains("storm")
|| s.Contains("acid")
|| s.Contains("fire")
|| s.Contains("disaster");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePowerIds,
"Spectacle",
"Power: {id}",
ambientStrength: 28f,
signalIds: null,
signalStrength: 92f,
signalPredicate: IsSpectaclePower);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "Power: {id}", 30f);
}
}
/// <summary>AI decisions - Signal for war/rebellion/kingdom-affecting; Ambient otherwise.</summary>
public static class DecisionInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsKingdomDecision(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("war")
|| s.Contains("rebellion")
|| s.Contains("revolt")
|| s.Contains("alliance")
|| s.Contains("king")
|| s.Contains("coup")
|| s.Contains("betray")
|| s.Contains("declare")
|| s.Contains("invade")
|| s.Contains("siege");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveDecisionIds,
"Politics",
"Decision: {id} ({a})",
ambientStrength: 32f,
signalIds: null,
signalStrength: 86f,
signalPredicate: IsKingdomDecision);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Politics", "Decision: {id} ({a})", 35f);
}
}
/// <summary>Items library - Signal for legendary/wonder subset.</summary>
public static class ItemInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsLegendaryItem(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("legendary")
|| s.Contains("mythic")
|| s.Contains("artifact")
|| s.Contains("relic")
|| s.Contains("divine")
|| s.Contains("demon")
|| s.Contains("dragon")
|| s.Contains("nuke")
|| s.Contains("excalibur")
|| s.Contains("wonder");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveItemIds,
"Item",
"Item forged: {id} ({a})",
ambientStrength: 36f,
signalIds: null,
signalStrength: 80f,
signalPredicate: IsLegendaryItem);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Item", "Item forged: {id} ({a})", 40f);
}
}
/// <summary>Subspecies traits - Signal for dramatic; Ambient fill-capped.</summary>
public static class SubspeciesTraitInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsDramatic(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("immortal")
|| s.Contains("giant")
|| s.Contains("tiny")
|| s.Contains("fire")
|| s.Contains("frost")
|| s.Contains("poison")
|| s.Contains("plague")
|| s.Contains("magic")
|| s.Contains("divine")
|| s.Contains("demon")
|| s.Contains("undead")
|| s.Contains("dragon")
|| s.Contains("evolve")
|| s.Contains("mutate");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds,
"Evolution",
"Subspecies trait: {id}",
ambientStrength: 34f,
signalIds: null,
signalStrength: 78f,
signalPredicate: IsDramatic);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Evolution", "Subspecies trait: {id}", 36f);
}
}
/// <summary>Genes - Ambient default.</summary>
public static class GeneInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveGeneIds,
"Genetics",
"Gene: {id} ({a})",
ambientStrength: 30f,
signalIds: null,
signalStrength: 70f,
signalPredicate: id => id != null && (id.Contains("warfare") || id.Contains("magic")));
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "Gene: {id} ({a})", 30f);
}
}
/// <summary>Phenotypes - Ambient default.</summary>
public static class PhenotypeInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePhenotypeIds,
"Genetics",
"Phenotype: {id} ({a})",
ambientStrength: 28f,
signalIds: null,
signalStrength: 60f);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "Phenotype: {id} ({a})", 28f);
}
}
/// <summary>World laws - Ambient / location-only style.</summary>
public static class WorldLawInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveWorldLawIds,
"WorldLaw",
"World law: {id}",
ambientStrength: 40f,
signalIds: null,
signalStrength: 70f,
signalPredicate: id => id != null && (id.Contains("war") || id.Contains("death") || id.Contains("plague")));
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "WorldLaw", "World law: {id}", 40f);
}
}
/// <summary>Biomes - Ambient.</summary>
public static class BiomeInterestCatalog
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveBiomeIds,
"Biome",
"Biome: {id}",
ambientStrength: 26f,
signalIds: null,
signalStrength: 50f);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Biome", "Biome: {id}", 26f);
}
}

View file

@ -11,7 +11,11 @@ public enum InterestOwnership
Ambient
}
/// <summary>Shared registration helpers for domain event feeds.</summary>
/// <summary>
/// Sole unit-led publish API for the idle director.
/// Ownership rule: FollowUnit owns the reason; RelatedUnit required when the label claims a second party;
/// location-only civic events may omit FollowUnit but never invent a stranger subject.
/// </summary>
public static class EventFeedUtil
{
public static InterestCandidate Register(
@ -24,24 +28,54 @@ public static class EventFeedUtil
InterestOwnership ownership,
Vector3 position,
Actor follow,
Actor related = null,
bool locationOnly = false,
float minWatch = 4f,
float maxWatch = 22f,
InterestCompletionKind completion = InterestCompletionKind.FixedDwell)
{
if (string.IsNullOrEmpty(key) || position == Vector3.zero && follow == null)
if (string.IsNullOrEmpty(key))
{
return null;
}
if (follow == null && position == Vector3.zero)
Actor subject = follow;
if (subject != null && !subject.isAlive())
{
return null;
subject = null;
}
Vector3 pos = follow != null ? follow.current_position : position;
if (pos == Vector3.zero)
Actor relatedAlive = related;
if (relatedAlive != null && !relatedAlive.isAlive())
{
return null;
relatedAlive = null;
}
Vector3 pos = position;
if (subject != null)
{
pos = subject.current_position;
}
if (locationOnly)
{
if (pos == Vector3.zero || float.IsNaN(pos.x))
{
return null;
}
}
else
{
// Unit-led: require a living subject. Never invent a stranger.
if (subject == null)
{
return null;
}
if (pos == Vector3.zero || float.IsNaN(pos.x))
{
return null;
}
}
bool ambient = ownership == InterestOwnership.Ambient;
@ -63,13 +97,15 @@ public static class EventFeedUtil
Source = source ?? "event",
EventStrength = strength,
CharacterSignificance = ambient ? strength * 0.5f : 0f,
VisualConfidence = follow != null ? 0.7f : 0.35f,
VisualConfidence = subject != null ? 0.7f : 0.35f,
Position = pos,
FollowUnit = follow,
SubjectId = follow != null ? SafeId(follow) : 0,
FollowUnit = subject,
RelatedUnit = relatedAlive,
SubjectId = subject != null ? SafeId(subject) : 0,
RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0,
Label = label ?? assetId ?? key,
AssetId = assetId ?? "",
SpeciesId = follow?.asset != null ? follow.asset.id : "",
SpeciesId = subject?.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + (ambient ? 18f : 35f),
@ -81,6 +117,48 @@ public static class EventFeedUtil
return InterestRegistry.Upsert(candidate);
}
/// <summary>
/// Publish a fully built candidate through the registry (still the sole intake).
/// Prefer the typed Register overload for new feeds.
/// </summary>
public static InterestCandidate RegisterCandidate(InterestCandidate candidate)
{
if (candidate == null || string.IsNullOrEmpty(candidate.Key) || !candidate.HasValidPosition)
{
return null;
}
// Reject stranger subjects: dead follow with no position is already invalid via HasValidPosition.
if (candidate.FollowUnit != null && !candidate.FollowUnit.isAlive())
{
candidate.FollowUnit = null;
candidate.SubjectId = 0;
if (!candidate.HasValidPosition)
{
return null;
}
}
if (candidate.RelatedUnit != null && !candidate.RelatedUnit.isAlive())
{
candidate.RelatedUnit = null;
candidate.RelatedId = 0;
}
if (candidate.FollowUnit != null && candidate.SubjectId == 0)
{
candidate.SubjectId = SafeId(candidate.FollowUnit);
}
if (candidate.RelatedUnit != null && candidate.RelatedId == 0)
{
candidate.RelatedId = SafeId(candidate.RelatedUnit);
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
public static long SafeId(Actor actor)
{
if (actor == null)
@ -127,6 +205,9 @@ public static class EventFeedUtil
}
}
/// <summary>
/// Harness / diagnostics only. Must not be used as a silent camera subject for Signal events.
/// </summary>
public static Actor AnyAliveUnit()
{
try
@ -152,31 +233,46 @@ public static class EventFeedUtil
return null;
}
/// <summary>
/// Resolve a unit-led anchor without stranger fallback.
/// Preferred unit if alive; else nearest at position; else fail (caller may location-only).
/// </summary>
public static bool TryResolveOwnedAnchor(
Actor preferred,
Vector3 position,
out Actor follow,
out Vector3 pos,
bool allowNearestAtPosition = true)
{
follow = null;
pos = position;
if (preferred != null && preferred.isAlive())
{
follow = preferred;
pos = preferred.current_position;
return pos != Vector3.zero && !float.IsNaN(pos.x);
}
if (allowNearestAtPosition && position != Vector3.zero && !float.IsNaN(position.x))
{
follow = NearestUnit(position, 48f);
if (follow != null && follow.isAlive())
{
pos = follow.current_position;
return true;
}
}
follow = null;
pos = position;
return false;
}
/// <summary>Obsolete stranger path. Prefer TryResolveOwnedAnchor or location-only Register.</summary>
[System.Obsolete("Use TryResolveOwnedAnchor or location-only Register. Do not invent subjects.")]
public static bool TryResolveAnchor(Actor preferred, Vector3 position, out Actor follow, out Vector3 pos)
{
follow = preferred;
pos = position;
if (follow != null && follow.isAlive())
{
pos = follow.current_position;
return pos != Vector3.zero || true;
}
if (position != Vector3.zero)
{
follow = NearestUnit(position) ?? AnyAliveUnit();
pos = position;
return true;
}
follow = AnyAliveUnit();
if (follow == null)
{
pos = Vector3.zero;
return false;
}
pos = follow.current_position;
return true;
return TryResolveOwnedAnchor(preferred, position, out follow, out pos, allowNearestAtPosition: true);
}
}

View file

@ -927,6 +927,17 @@ internal static class HarnessScenarios
Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"),
Step("wr49", "wait", wait: 0.35f),
// Ownership: reason must belong to focused subject (not a stranger name).
Step("wr60", "spawn", asset: "human", count: 2),
Step("wr61", "interest_force_session", asset: "human",
label: "Cabo loses infected", tier: "Action", expect: "stranger_label"),
Step("wr62", "assert", expect: "reason_owns_focus"),
Step("wr63", "assert", expect: "dossier_not_contains", value: "Cabo"),
Step("wr64", "interest_force_session", asset: "human",
label: "Lovers: Alpha + Beta", tier: "Action", expect: "lovers_owned"),
Step("wr65", "assert", expect: "reason_owns_focus"),
Step("wr66", "wait", wait: 0.35f),
// Mass fight inject → Clash / Fighting scale beat.
Step("wr50", "interest_expire_pending", value: ""),
Step("wr51", "interest_inject", asset: "human", label: "MassClash", tier: "Action", expect: "mass_clash",
@ -1143,6 +1154,18 @@ internal static class HarnessScenarios
Step("et33", "assert", expect: "interest_has_key", value: "boat:"),
Step("et34", "domain_feed", asset: "building"),
Step("et35", "assert", expect: "interest_has_key", value: "building:"),
Step("et40", "domain_feed", asset: "spell", value: "summon_lightning"),
Step("et41", "assert", expect: "interest_has_key", value: "spell:summon_lightning"),
Step("et42", "domain_feed", asset: "item", value: "sword"),
Step("et43", "assert", expect: "interest_has_key", value: "item:"),
Step("et44", "domain_feed", asset: "decision", value: "declare_war"),
Step("et45", "assert", expect: "interest_has_key", value: "decision:"),
Step("et46", "domain_feed", asset: "power", value: "lightning"),
Step("et47", "assert", expect: "interest_has_key", value: "power:"),
Step("et48", "domain_feed", asset: "subspecies_trait", value: "fire_blood"),
Step("et49", "assert", expect: "interest_has_key", value: "subspecies_trait:"),
Step("et50", "domain_feed", asset: "gene", value: "warfare_1"),
Step("et51", "assert", expect: "interest_has_key", value: "gene:"),
Step("et90", "assert", expect: "health"),
Step("et91", "assert", expect: "no_bad"),

View file

@ -85,6 +85,7 @@ public sealed class InterestCandidate
Score = TotalScore,
Position = Position,
FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit,
Label = Label,
CreatedAt = CreatedAt,
AssetId = AssetId,

View file

@ -134,7 +134,7 @@ public static class InterestDirector
return false;
}
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
return true;
}
@ -373,20 +373,17 @@ public static class InterestDirector
return;
}
// Prefer related (grief survivor is already FollowUnit); try related then participants.
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
return;
}
Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f);
if (near != null)
{
_current.FollowUnit = near;
CameraDirector.Watch(_current.ToInterestEvent());
}
// No owned related: end the scene rather than attach a nearby stranger.
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
private static void EndCurrent(float now, string reason)

View file

@ -11,6 +11,7 @@ public sealed class InterestEvent
public float Score;
public Vector3 Position;
public Actor FollowUnit;
public Actor RelatedUnit;
public string Label;
public float CreatedAt;
public string AssetId;
@ -20,6 +21,8 @@ public sealed class InterestEvent
public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive();
public bool HasRelatedUnit => RelatedUnit != null && RelatedUnit.isAlive();
public bool HasValidPosition =>
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
}

View file

@ -106,29 +106,47 @@ public static class InterestFeeds
unit = message.unit;
}
if (!EventFeedUtil.TryResolveAnchor(unit, position, out Actor follow, out Vector3 pos))
{
return;
}
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
string kingdom = message.special1 ?? "";
string key = "worldlog:" + assetId + ":" + kingdom;
float boost = PeekCivicBoost(kingdom, assetId);
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
string label = entry.MakeLabel(message);
EventFeedUtil.Register(
key,
"worldlog",
category,
entry.MakeLabel(message),
assetId,
evtSeed + boost,
entry.OwnsCamera,
pos,
follow,
entry.MinWatch,
entry.MaxWatch);
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
{
EventFeedUtil.Register(
key,
"worldlog",
category,
label,
assetId,
evtSeed + boost,
entry.OwnsCamera,
pos,
follow,
minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch);
return;
}
// Civic / disaster without a real unit: location-only (no stranger subject).
if (position != Vector3.zero && !float.IsNaN(position.x))
{
EventFeedUtil.Register(
key,
"worldlog",
category,
label,
assetId,
evtSeed + boost,
entry.OwnsCamera,
position,
follow: null,
locationOnly: true,
minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch);
}
}
/// <summary>Legacy / discovery / harness enqueue path → registry.</summary>
@ -187,7 +205,7 @@ public static class InterestFeeds
Completion = completion
};
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
public static InterestCandidate RegisterHarness(
@ -277,7 +295,7 @@ public static class InterestFeeds
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
@ -317,7 +335,7 @@ public static class InterestFeeds
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnStatusChange(Actor actor, string statusId, bool gained)
@ -366,9 +384,9 @@ public static class InterestFeeds
entry.OwnsCamera,
actor.current_position,
actor,
2f,
22f,
InterestCompletionKind.StatusPhase);
minWatch: 2f,
maxWatch: 22f,
completion: InterestCompletionKind.StatusPhase);
}
public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label)
@ -411,7 +429,7 @@ public static class InterestFeeds
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
private static void DrainHappiness()
@ -535,7 +553,7 @@ public static class InterestFeeds
}
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
/// <summary>Harness / push path: ingest one occurrence into the registry now.</summary>
@ -617,7 +635,7 @@ public static class InterestFeeds
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
private static void TickScanner()
@ -668,7 +686,7 @@ public static class InterestFeeds
c.StatusId = statusId;
c.LastSeenAt = Time.unscaledTime;
c.ExpiresAt = Time.unscaledTime + 20f;
InterestRegistry.Upsert(c);
EventFeedUtil.RegisterCandidate(c);
return true;
}
}

View file

@ -84,6 +84,21 @@ public static class InterestRegistry
{
if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null)
{
// Ownership merge: same key must not adopt a different subject's FollowUnit/Label.
bool sameSubject = incoming.SubjectId == 0
|| existing.SubjectId == 0
|| incoming.SubjectId == existing.SubjectId;
if (!sameSubject)
{
// Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly.
existing.LastSeenAt = now;
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
existing.Selected = false;
return existing;
}
existing.LastSeenAt = now;
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
@ -100,6 +115,9 @@ public static class InterestRegistry
if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive())
{
existing.FollowUnit = incoming.FollowUnit;
existing.SubjectId = incoming.SubjectId != 0
? incoming.SubjectId
: EventFeedUtil.SafeId(incoming.FollowUnit);
existing.Position = incoming.FollowUnit.current_position;
}
else if (incoming.Position != Vector3.zero)

View file

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Live-seeded interest catalogs: every live library id is authored (Ambient default),
/// with curated Signal overlays for spectacle / kingdom-affecting entries.
/// </summary>
public static class LiveLibraryInterest
{
public static void EnsureSeeded(
Dictionary<string, DiscreteEventEntry> entries,
Func<List<string>> enumerateLive,
string category,
string labelTemplate,
float ambientStrength,
HashSet<string> signalIds,
float signalStrength,
Func<string, bool> signalPredicate = null)
{
if (entries == null || enumerateLive == null)
{
return;
}
List<string> live;
try
{
live = enumerateLive() ?? new List<string>();
}
catch
{
live = new List<string>();
}
for (int i = 0; i < live.Count; i++)
{
string id = live[i];
if (string.IsNullOrEmpty(id) || entries.ContainsKey(id))
{
continue;
}
bool signal = (signalIds != null && signalIds.Contains(id))
|| (signalPredicate != null && signalPredicate(id));
entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = signal ? signalStrength : ambientStrength,
Category = category,
OwnsCamera = signal ? InterestOwnership.Signal : InterestOwnership.Ambient,
CreatesInterest = true,
LabelTemplate = labelTemplate
};
}
}
public static DiscreteEventEntry Lookup(
Dictionary<string, DiscreteEventEntry> entries,
string id,
string category,
string labelTemplate,
float fallbackStrength = 40f)
{
if (!string.IsNullOrEmpty(id) && entries != null && entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = fallbackStrength,
Category = category,
OwnsCamera = InterestOwnership.Ambient,
LabelTemplate = labelTemplate,
IsFallback = true
};
}
}

View file

@ -70,7 +70,7 @@ public static class MetaEventPatches
object meta,
string labelTemplate)
{
Actor anchor = TryReadActor(meta) ?? EventFeedUtil.AnyAliveUnit();
Actor anchor = TryReadActor(meta);
string name = EventFeedUtil.SafeName(anchor);
if (string.IsNullOrEmpty(name) && meta != null)
{
@ -86,6 +86,12 @@ public static class MetaEventPatches
}
}
if (anchor == null)
{
// Meta without a living founder/leader: skip (no stranger subject).
return;
}
string label = (labelTemplate ?? "{a}")
.Replace("{a}", name ?? "")
.Replace("{id}", eventId);

View file

@ -18,11 +18,40 @@ public static class MetaInterestFeed
return;
}
Actor follow = EventFeedUtil.AnyAliveUnit();
Vector3 pos = follow != null ? follow.current_position : Vector3.zero;
if (follow == null && pos == Vector3.zero)
// Eras are world-scoped: location-only. Prefer camera center, else a known map tile.
Vector3 pos = Vector3.zero;
try
{
return;
if (Camera.main != null)
{
Vector3 cam = Camera.main.transform.position;
pos = new Vector3(cam.x, cam.y, 0f);
}
}
catch
{
pos = Vector3.zero;
}
if (pos == Vector3.zero)
{
try
{
if (MapBox.instance != null)
{
// Non-zero placeholder so location-only Register accepts the candidate.
pos = new Vector3(1f, 1f, 0f);
}
}
catch
{
pos = new Vector3(1f, 1f, 0f);
}
}
if (pos == Vector3.zero)
{
pos = new Vector3(1f, 1f, 0f);
}
string key = "era:" + entry.Id;
@ -35,7 +64,8 @@ public static class MetaInterestFeed
entry.EventStrength,
entry.OwnsCamera,
pos,
follow,
follow: null,
locationOnly: true,
minWatch: 6f,
maxWatch: 28f);
}
@ -47,14 +77,10 @@ public static class MetaInterestFeed
return;
}
Actor follow = anchor;
if (follow == null || !follow.isAlive())
{
follow = EventFeedUtil.AnyAliveUnit();
}
Actor follow = anchor != null && anchor.isAlive() ? anchor : null;
if (follow == null)
{
// Meta genesis without a founder: skip unit dossier rather than invent a subject.
return;
}

View file

@ -23,11 +23,7 @@ public static class PlotInterestFeed
Actor follow = subject;
if (follow == null || !follow.isAlive())
{
follow = EventFeedUtil.AnyAliveUnit();
}
if (follow == null)
{
// Plot without a living author: skip rather than invent a stranger.
return;
}

View file

@ -113,7 +113,7 @@ public static class RelationshipEventPatches
[HarmonyPostfix]
public static void PostfixNewFamily(Family __result, Actor pActor)
{
Actor anchor = pActor != null && pActor.isAlive() ? pActor : EventFeedUtil.AnyAliveUnit();
Actor anchor = pActor != null && pActor.isAlive() ? pActor : null;
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
}
@ -121,7 +121,7 @@ public static class RelationshipEventPatches
[HarmonyPostfix]
public static void PostfixRemoveFamily(Family pObject)
{
RelationshipInterestFeed.EmitFamily("family_removed", pObject, EventFeedUtil.AnyAliveUnit());
RelationshipInterestFeed.EmitFamily("family_removed", pObject, null);
}
[HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")]
@ -157,7 +157,7 @@ public static class RelationshipEventPatches
return;
}
RelationshipInterestFeed.Emit("family_group_new", pActor, null);
RelationshipInterestFeed.Emit("family_group_new", pActor, ActorRelation.ResolvePackRelated(pActor));
}
[HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))]
@ -169,7 +169,7 @@ public static class RelationshipEventPatches
return;
}
RelationshipInterestFeed.Emit("family_group_join", pActor, null);
RelationshipInterestFeed.Emit("family_group_join", pActor, ActorRelation.ResolvePackRelated(pActor));
}
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
@ -181,6 +181,6 @@ public static class RelationshipEventPatches
return;
}
RelationshipInterestFeed.Emit("family_group_leave", pActor, null);
RelationshipInterestFeed.Emit("family_group_leave", pActor, ActorRelation.ResolvePackRelated(pActor));
}
}

View file

@ -2,7 +2,7 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>Registers relationship lifecycle interest candidates.</summary>
/// <summary>Registers relationship lifecycle interest candidates under the ownership contract.</summary>
public static class RelationshipInterestFeed
{
public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null)
@ -18,10 +18,41 @@ public static class RelationshipInterestFeed
return;
}
Actor relatedAlive = related != null && related.isAlive() ? related : null;
// Pack / join labels claim a second party - resolve live related or drop the dishonest claim.
bool claimsPack = eventId == "family_group_new"
|| eventId == "family_group_join"
|| eventId == "family_group_leave"
|| eventId == "set_lover"
|| eventId == "add_child";
if (claimsPack && relatedAlive == null)
{
relatedAlive = ActorRelation.ResolvePackRelated(subject);
}
if (claimsPack && relatedAlive == null)
{
// Honest Ambient: subject only, no pack/lover claim in the label.
string soloLabel = SoloLabel(eventId, EventFeedUtil.SafeName(subject));
string soloKey = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":solo";
EventFeedUtil.Register(
soloKey,
"relationship",
entry.Category,
soloLabel,
entry.Id,
Mathf.Min(entry.EventStrength, 40f),
InterestOwnership.Ambient,
subject.current_position,
subject);
return;
}
string a = EventFeedUtil.SafeName(subject);
string b = EventFeedUtil.SafeName(related);
string b = EventFeedUtil.SafeName(relatedAlive);
string label = string.IsNullOrEmpty(labelOverride) ? entry.MakeLabel(a, b) : labelOverride;
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(related);
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(relatedAlive);
EventFeedUtil.Register(
key,
"relationship",
@ -31,7 +62,8 @@ public static class RelationshipInterestFeed
entry.EventStrength,
entry.OwnsCamera,
subject.current_position,
subject);
subject,
related: relatedAlive);
}
public static void EmitFamily(string eventId, object family, Actor anchor)
@ -42,17 +74,19 @@ public static class RelationshipInterestFeed
}
DiscreteEventEntry entry = RelationshipEventCatalog.GetOrFallback(eventId);
Actor follow = anchor;
if (follow == null || !follow.isAlive())
Actor follow = anchor != null && anchor.isAlive() ? anchor : null;
if (follow == null)
{
follow = EventFeedUtil.AnyAliveUnit();
follow = FirstFamilyMember(family);
}
if (follow == null)
{
// No owned member - skip rather than invent a stranger.
return;
}
Actor related = ActorRelation.FirstFamilyPeer(follow);
string name = "";
try
{
@ -65,16 +99,99 @@ public static class RelationshipInterestFeed
// ignore
}
string key = "rel:" + entry.Id + ":" + (name ?? "family");
if (string.IsNullOrEmpty(name))
{
name = EventFeedUtil.SafeName(follow);
}
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow) + ":" + (name ?? "family");
EventFeedUtil.Register(
key,
"relationship",
entry.Category,
entry.MakeLabel(name, ""),
entry.MakeLabel(name, EventFeedUtil.SafeName(related)),
entry.Id,
entry.EventStrength,
entry.OwnsCamera,
follow.current_position,
follow);
follow,
related: related);
}
private static string SoloLabel(string eventId, string name)
{
switch (eventId)
{
case "family_group_join":
return "Gathers with kin: " + name;
case "family_group_new":
return "Starts a kin group: " + name;
case "family_group_leave":
return "Leaves kin: " + name;
case "set_lover":
return "Seeking a bond: " + name;
case "add_child":
return "Family grows: " + name;
default:
return name;
}
}
private static Actor FirstFamilyMember(object family)
{
if (family == null)
{
return null;
}
try
{
foreach (Actor member in ActorRelation.EnumerateFamilyMembers(
ActorRelation.ResolveActor(
family.GetType().GetField("founder")?.GetValue(family)
?? family.GetType().GetProperty("founder")?.GetValue(family, null))
?? null,
max: 1))
{
return member;
}
}
catch
{
// fall through
}
// Direct units list on the family meta object.
try
{
object list = family.GetType().GetField("units")?.GetValue(family)
?? family.GetType().GetProperty("units")?.GetValue(family, null)
?? family.GetType().GetMethod("getUnits")?.Invoke(family, null)
?? family.GetType().GetMethod("getSimpleList")?.Invoke(family, null);
if (list is System.Collections.IEnumerable enumerable)
{
foreach (object raw in enumerable)
{
Actor a = ActorRelation.ResolveActor(raw);
if (a != null && a.isAlive())
{
return a;
}
}
}
Actor founder = family.GetType().GetField("founder")?.GetValue(family) as Actor
?? family.GetType().GetProperty("founder")?.GetValue(family, null) as Actor;
if (founder != null && founder.isAlive())
{
return founder;
}
}
catch
{
// ignore
}
return null;
}
}

View file

@ -212,11 +212,8 @@ public sealed class UnitDossier
}
/// <summary>
/// Story beat for the dossier reason row: what is happening in this shot.
/// No tier badges, no job append, no nametag repeats.
/// Fill / ambient scenes with no sharper beat leave the reason empty (row hidden).
/// Follow-up (not blocking): expand event/character tags and optional beatPhrases
/// in scoring-model.json once playtests show thin beats.
/// Story beat for the dossier reason row: owned only by the matched current candidate.
/// Empty reason beats a wrong line - no watchLabel / spectacle / activity fallbacks.
/// </summary>
private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor)
{
@ -226,6 +223,10 @@ public sealed class UnitDossier
}
InterestCandidate scene = MatchingScene(actor, d);
if (scene == null)
{
return "";
}
string combat = CombatBeat(d, scene);
if (!string.IsNullOrEmpty(combat))
@ -240,55 +241,18 @@ public sealed class UnitDossier
}
// Ambient filler: no story beat - hide the reason row.
if (scene != null && InterestScoring.IsFillScore(scene.TotalScore)
&& !InterestScoring.IsCombatAction(scene))
if (InterestScoring.IsFillScore(scene.TotalScore) && !InterestScoring.IsCombatAction(scene))
{
return "";
}
string fromScene = SceneLabelBeat(scene, d);
if (!string.IsNullOrEmpty(fromScene))
{
return fromScene;
}
string fromWatch = EventLabelBeat(watchLabel, d);
if (!string.IsNullOrEmpty(fromWatch))
{
return fromWatch;
}
if (d.IsSpectacle)
{
return "Spectacle";
}
string activity = ActivityBeat(actor);
if (!string.IsNullOrEmpty(activity))
{
return activity;
}
if (d.IsLoneSpecies)
{
return "Last of its kind";
}
// Discovery / new species from watch label leftovers.
string discovery = CleanBeat(ShortenWatchLabel(watchLabel, d));
if (!string.IsNullOrEmpty(discovery)
&& discovery.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return "New species";
}
return "";
return SceneLabelBeat(scene, d) ?? "";
}
private static InterestCandidate MatchingScene(Actor actor, UnitDossier d)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene == null)
if (scene == null || actor == null)
{
return null;
}
@ -298,7 +262,7 @@ public sealed class UnitDossier
return scene;
}
if (actor != null && scene.SubjectId == d.UnitId)
if (scene.SubjectId != 0 && scene.SubjectId == d.UnitId)
{
return scene;
}
@ -308,6 +272,11 @@ public sealed class UnitDossier
return scene;
}
if (scene.RelatedId != 0 && scene.RelatedId == d.UnitId)
{
return scene;
}
return null;
}
@ -397,7 +366,174 @@ public sealed class UnitDossier
return "";
}
return EventLabelBeat(scene.Label, d);
string beat = EventLabelBeat(scene.Label, d);
if (string.IsNullOrEmpty(beat))
{
return "";
}
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
return "";
}
// Ownership: reject beats that lead with someone else's proper name.
if (LeadsWithForeignName(beat, scene))
{
return "";
}
return beat;
}
private static bool LeadsWithForeignName(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null)
{
return false;
}
string first = FirstToken(reason);
if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first))
{
return false;
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
if (!string.IsNullOrEmpty(subjectName)
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrEmpty(relatedName)
&& first.Equals(FirstToken(relatedName), System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Leading token looks like a proper name that is not the owned subject/related.
return first.Length >= 3 && char.IsLetter(first[0]);
}
private static string FirstToken(string text)
{
if (string.IsNullOrEmpty(text))
{
return "";
}
int i = 0;
while (i < text.Length && (char.IsWhiteSpace(text[i]) || text[i] == ':' || text[i] == '·'))
{
i++;
}
int start = i;
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-'))
{
i++;
}
return start < i ? text.Substring(start, i - start) : "";
}
private static bool IsOwnedBeatKeyword(string token)
{
if (string.IsNullOrEmpty(token))
{
return true;
}
switch (token.ToLowerInvariant())
{
case "fighting":
case "war":
case "battle":
case "clash":
case "grief":
case "mourning":
case "mourn":
case "slain":
case "lovers":
case "lover":
case "new":
case "baby":
case "book":
case "building":
case "boat":
case "meta":
case "seeking":
case "joins":
case "leaves":
case "family":
case "starts":
case "gathers":
case "damages":
case "consumes":
case "spectacle":
case "in":
return true;
default:
return false;
}
}
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
{
return false;
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
{
continue;
}
string name = EventFeedUtil.SafeName(unit);
if (string.IsNullOrEmpty(name) || name.Length < 3)
{
continue;
}
if (!string.IsNullOrEmpty(subjectName)
&& name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrEmpty(relatedName)
&& name.Equals(relatedName, System.StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (reason.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
}
catch
{
// ignore
}
return false;
}
private static string EventLabelBeat(string label, UnitDossier d)

View file

@ -132,29 +132,8 @@ public static class WarInterestFeed
return true;
}
// Fall back to any living unit.
try
{
if (World.world?.units != null)
{
foreach (Actor actor in World.world.units)
{
if (actor == null || !actor.isAlive())
{
continue;
}
follow = actor;
position = actor.current_position;
return true;
}
}
}
catch
{
// ignore
}
// No kingdom unit found - location-only if we have a capital position is handled above.
// Never invent a global random unit as war subject.
return false;
}

View file

@ -413,6 +413,7 @@ public static class WatchCaption
{
if (interest != null)
{
// Location-only / no unit: tip text for logs; dossier has no owned subject reason.
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
@ -434,7 +435,9 @@ public static class WatchCaption
return;
}
SetFromActor(unit, interest?.Label);
// Reason ownership comes from InterestDirector.CurrentCandidate via MatchingScene,
// not from a sticky InterestEvent.Label / LastWatchLabel.
SetFromActor(unit, label: null);
}
public static void SetFromActor(Actor actor, string label = null)

View file

@ -173,7 +173,7 @@ public static class WorldActivityScanner
: InterestCompletionKind.CharacterVignette)
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.16.32",
"version": "0.18.1",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator"
}