Phase 1
This commit is contained in:
parent
eaa431cc3d
commit
020d73fa9c
28 changed files with 2331 additions and 207 deletions
|
|
@ -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>();
|
||||
|
|
|
|||
368
IdleSpectator/ActorRelation.cs
Normal file
368
IdleSpectator/ActorRelation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
310
IdleSpectator/DeferredEventPatches.cs
Normal file
310
IdleSpectator/DeferredEventPatches.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
198
IdleSpectator/DeferredInterestFeed.cs
Normal file
198
IdleSpectator/DeferredInterestFeed.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
391
IdleSpectator/DeferredLibraryCatalogs.cs
Normal file
391
IdleSpectator/DeferredLibraryCatalogs.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ public sealed class InterestCandidate
|
|||
Score = TotalScore,
|
||||
Position = Position,
|
||||
FollowUnit = FollowUnit,
|
||||
RelatedUnit = RelatedUnit,
|
||||
Label = Label,
|
||||
CreatedAt = CreatedAt,
|
||||
AssetId = AssetId,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
82
IdleSpectator/LiveLibraryInterest.cs
Normal file
82
IdleSpectator/LiveLibraryInterest.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ public static class WorldActivityScanner
|
|||
: InterestCompletionKind.CharacterVignette)
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue