feat(actor-relation): enhance parent-child relationship tracking and event handling
- Introduce methods to observe and remember parent-child links for actors - Implement caching for observed relationships to improve performance - Update narrative event store to index child events by parent and child - Refactor LifeSagaPanel to ensure stable cast membership during saga sessions - Enhance documentation to clarify relationship handling and event indexing
This commit is contained in:
parent
1bbe642e5b
commit
44999fb653
11 changed files with 367 additions and 119 deletions
|
|
@ -10,6 +10,70 @@ namespace IdleSpectator;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ActorRelation
|
public static class ActorRelation
|
||||||
{
|
{
|
||||||
|
private static readonly MethodInfo GetChildrenMethod = typeof(Actor).GetMethod(
|
||||||
|
"getChildren",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
||||||
|
binder: null,
|
||||||
|
types: Type.EmptyTypes,
|
||||||
|
modifiers: null);
|
||||||
|
private static readonly MethodInfo GetParentsMethod = typeof(Actor).GetMethod(
|
||||||
|
"getParents",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
||||||
|
binder: null,
|
||||||
|
types: Type.EmptyTypes,
|
||||||
|
modifiers: null);
|
||||||
|
private static readonly Dictionary<long, List<long>> ObservedChildrenByParent =
|
||||||
|
new Dictionary<long, List<long>>();
|
||||||
|
private static readonly HashSet<long> ParentLinksScanned =
|
||||||
|
new HashSet<long>();
|
||||||
|
|
||||||
|
public static void ClearObservedRelations()
|
||||||
|
{
|
||||||
|
ObservedChildrenByParent.Clear();
|
||||||
|
ParentLinksScanned.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ObserveParentLinks(Actor child)
|
||||||
|
{
|
||||||
|
if (child == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long childId = EventFeedUtil.SafeId(child);
|
||||||
|
if (childId == 0 || !ParentLinksScanned.Add(childId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Actor parent in EnumerateParents(child, 2))
|
||||||
|
{
|
||||||
|
RememberParentChild(parent, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RememberParentChild(Actor parent, Actor child)
|
||||||
|
{
|
||||||
|
long parentId = EventFeedUtil.SafeId(parent);
|
||||||
|
long childId = EventFeedUtil.SafeId(child);
|
||||||
|
if (parentId == 0 || childId == 0 || parentId == childId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParentLinksScanned.Add(childId);
|
||||||
|
if (!ObservedChildrenByParent.TryGetValue(parentId, out List<long> children))
|
||||||
|
{
|
||||||
|
children = new List<long>(2);
|
||||||
|
ObservedChildrenByParent[parentId] = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!children.Contains(childId))
|
||||||
|
{
|
||||||
|
children.Add(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static Actor GetLover(Actor actor)
|
public static Actor GetLover(Actor actor)
|
||||||
{
|
{
|
||||||
if (actor == null)
|
if (actor == null)
|
||||||
|
|
@ -154,13 +218,14 @@ public static class ActorRelation
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seen = new HashSet<long>();
|
||||||
|
int yielded = 0;
|
||||||
IEnumerable children = null;
|
IEnumerable children = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes);
|
if (GetChildrenMethod != null)
|
||||||
if (getChildren != null)
|
|
||||||
{
|
{
|
||||||
children = getChildren.Invoke(actor, null) as IEnumerable;
|
children = GetChildrenMethod.Invoke(actor, null) as IEnumerable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|
@ -168,21 +233,75 @@ public static class ActorRelation
|
||||||
children = null;
|
children = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (children == null)
|
if (children != null)
|
||||||
{
|
{
|
||||||
yield break;
|
foreach (object raw in children)
|
||||||
|
{
|
||||||
|
Actor child = raw as Actor ?? ResolveActor(raw);
|
||||||
|
if (!IsDistinctLivingRelation(actor, child, seen))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return child;
|
||||||
|
yielded++;
|
||||||
|
if (yielded >= max)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int yielded = 0;
|
if (ObservedChildrenByParent.TryGetValue(
|
||||||
foreach (object raw in children)
|
EventFeedUtil.SafeId(actor),
|
||||||
|
out List<long> observedChildren))
|
||||||
{
|
{
|
||||||
Actor child = raw as Actor ?? ResolveActor(raw);
|
for (int i = 0; i < observedChildren.Count; i++)
|
||||||
if (child == null || !child.isAlive())
|
{
|
||||||
|
Actor child = EventFeedUtil.FindAliveById(observedChildren[i]);
|
||||||
|
if (!IsDistinctLivingRelation(actor, child, seen))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return child;
|
||||||
|
yielded++;
|
||||||
|
if (yielded >= max)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some saved civilized bloodlines retain parent links on each child while
|
||||||
|
// the parent's getChildren() view is empty or incomplete. Family membership
|
||||||
|
// is not directional, so only promote a peer after its own parent data
|
||||||
|
// explicitly points back to this actor.
|
||||||
|
long actorId = EventFeedUtil.SafeId(actor);
|
||||||
|
int familyScanMax = Math.Min(64, Math.Max(16, max * 8));
|
||||||
|
foreach (Actor peer in EnumerateFamilyMembers(actor, familyScanMax))
|
||||||
|
{
|
||||||
|
if (peer == null || peer == actor)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return child;
|
bool namesActorAsParent = false;
|
||||||
|
foreach (Actor parent in EnumerateParents(peer, 2))
|
||||||
|
{
|
||||||
|
if (EventFeedUtil.SafeId(parent) == actorId)
|
||||||
|
{
|
||||||
|
namesActorAsParent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!namesActorAsParent || !IsDistinctLivingRelation(actor, peer, seen))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return peer;
|
||||||
yielded++;
|
yielded++;
|
||||||
if (yielded >= max)
|
if (yielded >= max)
|
||||||
{
|
{
|
||||||
|
|
@ -199,7 +318,20 @@ public static class ActorRelation
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
object parents = ReadMember(actor, "getParents", "get_parents", "parents");
|
object parents = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
parents = GetParentsMethod?.Invoke(actor, null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
parents = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parents == null)
|
||||||
|
{
|
||||||
|
parents = ReadMember(actor, "get_parents", "parents");
|
||||||
|
}
|
||||||
if (parents == null)
|
if (parents == null)
|
||||||
{
|
{
|
||||||
parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids");
|
parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids");
|
||||||
|
|
@ -366,39 +498,7 @@ public static class ActorRelation
|
||||||
|
|
||||||
private static Actor FindUnitById(long id)
|
private static Actor FindUnitById(long id)
|
||||||
{
|
{
|
||||||
if (id == 0 || World.world?.units == null)
|
return EventFeedUtil.FindAliveById(id);
|
||||||
{
|
|
||||||
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 ResolveActorId(object raw)
|
private static Actor ResolveActorId(object raw)
|
||||||
|
|
@ -481,7 +581,12 @@ public static class ActorRelation
|
||||||
return field.GetValue(instance);
|
return field.GetValue(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
MethodInfo method = type.GetMethod(name, Type.EmptyTypes);
|
MethodInfo method = type.GetMethod(
|
||||||
|
name,
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
||||||
|
binder: null,
|
||||||
|
types: Type.EmptyTypes,
|
||||||
|
modifiers: null);
|
||||||
if (method != null)
|
if (method != null)
|
||||||
{
|
{
|
||||||
return method.Invoke(instance, null);
|
return method.Invoke(instance, null);
|
||||||
|
|
|
||||||
|
|
@ -1435,9 +1435,20 @@ public static class AgentHarness
|
||||||
if (child != null && parent != null && child.isAlive() && parent.isAlive()
|
if (child != null && parent != null && child.isAlive() && parent.isAlive()
|
||||||
&& child != parent)
|
&& child != parent)
|
||||||
{
|
{
|
||||||
child.setParent1(parent);
|
bool parent2 = string.Equals(
|
||||||
|
(cmd.value ?? "").Trim(),
|
||||||
|
"parent2",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (parent2)
|
||||||
|
{
|
||||||
|
child.setParent2(parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
child.setParent1(parent);
|
||||||
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
detail = $"parent={SafeName(parent)} child={SafeName(child)}";
|
detail = $"parent={SafeName(parent)} child={SafeName(child)} slot={(parent2 ? 2 : 1)}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -3683,14 +3694,26 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "saga_cast_dedupe_probe":
|
case "saga_cast_stability_probe":
|
||||||
{
|
{
|
||||||
bool ok = LifeSagaPanel.HarnessProbeScopedCastDedup(out string detail);
|
bool ok = LifeSagaPanel.HarnessProbeStableCastSnapshot(out string detail);
|
||||||
if (ok) _cmdOk++; else _cmdFail++;
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
Emit(cmd, ok, detail: detail);
|
Emit(cmd, ok, detail: detail);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "saga_memory_clear_only":
|
||||||
|
LifeSagaMemory.Clear();
|
||||||
|
_cmdOk++;
|
||||||
|
Emit(cmd, ok: true, detail: "saga_memory_only_cleared");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "narrative_story_clear_only":
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
|
_cmdOk++;
|
||||||
|
Emit(cmd, ok: true, detail: "narrative_story_only_cleared");
|
||||||
|
break;
|
||||||
|
|
||||||
case "interest_saga_clear":
|
case "interest_saga_clear":
|
||||||
LifeSagaRoster.Clear();
|
LifeSagaRoster.Clear();
|
||||||
LifeSagaMemory.Clear();
|
LifeSagaMemory.Clear();
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,11 @@ public static class RelationshipEventPatches
|
||||||
|
|
||||||
private static void EmitAddChild(Actor parent, Actor child, bool increaseChildren)
|
private static void EmitAddChild(Actor parent, Actor child, bool increaseChildren)
|
||||||
{
|
{
|
||||||
|
if (increaseChildren && EventOutcome.IsLiving(parent) && EventOutcome.IsLiving(child))
|
||||||
|
{
|
||||||
|
ActorRelation.RememberParentChild(parent, child);
|
||||||
|
}
|
||||||
|
|
||||||
EventObservation.Confirm(
|
EventObservation.Confirm(
|
||||||
"setParent",
|
"setParent",
|
||||||
"add_child",
|
"add_child",
|
||||||
|
|
|
||||||
|
|
@ -266,8 +266,10 @@ internal static class HarnessScenarios
|
||||||
return SagaSessionReset();
|
return SagaSessionReset();
|
||||||
case "saga_relation_cache_revision":
|
case "saga_relation_cache_revision":
|
||||||
return SagaRelationCacheRevision();
|
return SagaRelationCacheRevision();
|
||||||
case "saga_cast_dedupe_scoped":
|
case "saga_cast_stable_snapshot":
|
||||||
return SagaCastDedupeScoped();
|
return SagaCastStableSnapshot();
|
||||||
|
case "saga_live_child_reverse":
|
||||||
|
return SagaLiveChildReverse();
|
||||||
case "saga_hover_read_pause":
|
case "saga_hover_read_pause":
|
||||||
return SagaHoverReadPause();
|
return SagaHoverReadPause();
|
||||||
case "saga_admit_roles":
|
case "saga_admit_roles":
|
||||||
|
|
@ -488,7 +490,7 @@ internal static class HarnessScenarios
|
||||||
Nested("reg_narrative_replay", "narrative_editorial_replay"),
|
Nested("reg_narrative_replay", "narrative_editorial_replay"),
|
||||||
Nested("reg_caption_status_banner", "caption_status_banner_live"),
|
Nested("reg_caption_status_banner", "caption_status_banner_live"),
|
||||||
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
|
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
|
||||||
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"),
|
Nested("reg_saga_cast_snapshot", "saga_cast_stable_snapshot"),
|
||||||
Nested("reg_story_aftermath_war", "story_aftermath_war"),
|
Nested("reg_story_aftermath_war", "story_aftermath_war"),
|
||||||
Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"),
|
Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"),
|
||||||
Nested("reg_story_spine", "story_spine_scoped"),
|
Nested("reg_story_spine", "story_spine_scoped"),
|
||||||
|
|
@ -4300,17 +4302,65 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<HarnessCommand> SagaCastDedupeScoped()
|
private static List<HarnessCommand> SagaCastStableSnapshot()
|
||||||
{
|
{
|
||||||
return new List<HarnessCommand>
|
return new List<HarnessCommand>
|
||||||
{
|
{
|
||||||
Step("scds0", "wait_world"),
|
Step("scds0", "dismiss_windows"),
|
||||||
Step("scds1", "saga_cast_dedupe_probe"),
|
Step("scds1", "wait_world"),
|
||||||
Step("scds2", "assert", expect: "no_bad"),
|
Step("scds2", "set_setting", expect: "enabled", value: "true"),
|
||||||
|
Step("scds3", "spawn", asset: "human", count: 2),
|
||||||
|
Step("scds4", "spectator", value: "off"),
|
||||||
|
Step("scds5", "spectator", value: "on"),
|
||||||
|
Step("scds6", "interest_saga_clear"),
|
||||||
|
Step("scds7", "pick_unit", asset: "human"),
|
||||||
|
Step("scds8", "happiness_remember_partner"),
|
||||||
|
Step("scds9", "pick_unit", asset: "human", value: "other"),
|
||||||
|
Step("scds10", "focus", asset: "human"),
|
||||||
|
Step("scds11", "saga_force_admit_focus"),
|
||||||
|
Step("scds12", "saga_memory_record_focus", asset: "child"),
|
||||||
|
Step("scds13", "assert", expect: "saga_cast_contains", value: "Child"),
|
||||||
|
Step("scds14", "saga_memory_clear_only"),
|
||||||
|
Step("scds15", "assert", expect: "saga_cast_contains", value: "Child"),
|
||||||
|
Step("scds16", "saga_memory_record_focus", asset: "child"),
|
||||||
|
Step("scds17", "assert", expect: "saga_memory_has_focus", value: "true"),
|
||||||
|
Step("scds18", "saga_cast_stability_probe"),
|
||||||
|
Step("scds19", "saga_show_hover_first"),
|
||||||
|
Step("scds20", "assert", expect: "saga_panel_bound"),
|
||||||
|
Step("scds21", "assert", expect: "saga_panel_cast_relations", value: "1"),
|
||||||
|
Step("scds22", "assert", expect: "saga_cast_contains", value: "Child"),
|
||||||
|
Step("scds23", "assert", expect: "no_bad"),
|
||||||
Step("scds99", "snapshot"),
|
Step("scds99", "snapshot"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> SagaLiveChildReverse()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("slcr0", "dismiss_windows"),
|
||||||
|
Step("slcr1", "wait_world"),
|
||||||
|
Step("slcr2", "set_setting", expect: "enabled", value: "true"),
|
||||||
|
Step("slcr3", "spawn", asset: "human", count: 2),
|
||||||
|
Step("slcr4", "spectator", value: "off"),
|
||||||
|
Step("slcr5", "spectator", value: "on"),
|
||||||
|
Step("slcr6", "interest_saga_clear"),
|
||||||
|
Step("slcr7", "pick_unit", asset: "human"),
|
||||||
|
Step("slcr8", "happiness_remember_partner"),
|
||||||
|
Step("slcr9", "pick_unit", asset: "human", value: "other"),
|
||||||
|
Step("slcr10", "focus", asset: "human"),
|
||||||
|
Step("slcr11", "relationship_set_parent", value: "parent2"),
|
||||||
|
Step("slcr12", "focus_partner"),
|
||||||
|
Step("slcr13", "saga_force_admit_focus"),
|
||||||
|
Step("slcr14", "assert", expect: "saga_cast_contains", value: "Child"),
|
||||||
|
Step("slcr15", "saga_memory_clear_only"),
|
||||||
|
Step("slcr16", "narrative_story_clear_only"),
|
||||||
|
Step("slcr17", "assert", expect: "saga_cast_contains", value: "Child"),
|
||||||
|
Step("slcr18", "assert", expect: "no_bad"),
|
||||||
|
Step("slcr99", "snapshot"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Character beat caption: live MC identity, prose bans, hover keeps beat.</summary>
|
/// <summary>Character beat caption: live MC identity, prose bans, hover keeps beat.</summary>
|
||||||
private static List<HarnessCommand> CaptionCharacterBeat()
|
private static List<HarnessCommand> CaptionCharacterBeat()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,10 @@ public static class NarrativeEventRecorder
|
||||||
NarrativeEventRecord existing = NarrativeEventStore.Get(id);
|
NarrativeEventRecord existing = NarrativeEventStore.Get(id);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
|
// The immutable event store and compact Saga memory have separate
|
||||||
|
// lifetimes. Re-observing a confirmed event should repair a missing
|
||||||
|
// presentation fact instead of leaving the relationship generic.
|
||||||
|
recordFact?.Invoke();
|
||||||
NarrativePresentationModel duplicatePresentation =
|
NarrativePresentationModel duplicatePresentation =
|
||||||
string.IsNullOrEmpty(eventId)
|
string.IsNullOrEmpty(eventId)
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,18 @@ public static class NarrativeEventStore
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, NarrativeEventRecord> Events =
|
private static readonly Dictionary<string, NarrativeEventRecord> Events =
|
||||||
new Dictionary<string, NarrativeEventRecord>(StringComparer.Ordinal);
|
new Dictionary<string, NarrativeEventRecord>(StringComparer.Ordinal);
|
||||||
|
private static readonly Dictionary<long, List<NarrativeEventRecord>> ChildEventsByParent =
|
||||||
|
new Dictionary<long, List<NarrativeEventRecord>>();
|
||||||
|
private static readonly Dictionary<long, List<NarrativeEventRecord>> ChildEventsByChild =
|
||||||
|
new Dictionary<long, List<NarrativeEventRecord>>();
|
||||||
|
|
||||||
public static int Count => Events.Count;
|
public static int Count => Events.Count;
|
||||||
|
|
||||||
public static void Clear()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
Events.Clear();
|
Events.Clear();
|
||||||
|
ChildEventsByParent.Clear();
|
||||||
|
ChildEventsByChild.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static NarrativeEventRecord Get(string id)
|
public static NarrativeEventRecord Get(string id)
|
||||||
|
|
@ -36,6 +42,7 @@ public static class NarrativeEventStore
|
||||||
}
|
}
|
||||||
|
|
||||||
Events[record.Id] = record;
|
Events[record.Id] = record;
|
||||||
|
Index(record);
|
||||||
NarrativePersistence.MarkDirty();
|
NarrativePersistence.MarkDirty();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -50,9 +57,24 @@ public static class NarrativeEventStore
|
||||||
}
|
}
|
||||||
|
|
||||||
Events[record.Id] = record;
|
Events[record.Id] = record;
|
||||||
|
Index(record);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<NarrativeEventRecord> ChildrenOf(long parentId)
|
||||||
|
{
|
||||||
|
return parentId != 0 && ChildEventsByParent.TryGetValue(parentId, out List<NarrativeEventRecord> records)
|
||||||
|
? records
|
||||||
|
: Array.Empty<NarrativeEventRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<NarrativeEventRecord> ParentsOf(long childId)
|
||||||
|
{
|
||||||
|
return childId != 0 && ChildEventsByChild.TryGetValue(childId, out List<NarrativeEventRecord> records)
|
||||||
|
? records
|
||||||
|
: Array.Empty<NarrativeEventRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keep the event side of the runtime graph aligned with retained developments.
|
/// Keep the event side of the runtime graph aligned with retained developments.
|
||||||
/// Recent unprojected observations fill any remaining room so dedupe remains useful.
|
/// Recent unprojected observations fill any remaining room so dedupe remains useful.
|
||||||
|
|
@ -99,6 +121,50 @@ public static class NarrativeEventStore
|
||||||
if (!retained.Contains(id)) remove.Add(id);
|
if (!retained.Contains(id)) remove.Add(id);
|
||||||
}
|
}
|
||||||
for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]);
|
for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]);
|
||||||
|
if (remove.Count > 0)
|
||||||
|
{
|
||||||
|
RebuildRelationIndexes();
|
||||||
|
}
|
||||||
return Mathf.Max(0, before - Events.Count);
|
return Mathf.Max(0, before - Events.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void Index(NarrativeEventRecord record)
|
||||||
|
{
|
||||||
|
if (record == null || record.Kind != NarrativeEventKind.ChildBorn)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddIndexed(ChildEventsByParent, record.SubjectId, record);
|
||||||
|
AddIndexed(ChildEventsByChild, record.OtherId, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddIndexed(
|
||||||
|
Dictionary<long, List<NarrativeEventRecord>> index,
|
||||||
|
long id,
|
||||||
|
NarrativeEventRecord record)
|
||||||
|
{
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!index.TryGetValue(id, out List<NarrativeEventRecord> records))
|
||||||
|
{
|
||||||
|
records = new List<NarrativeEventRecord>(2);
|
||||||
|
index[id] = records;
|
||||||
|
}
|
||||||
|
|
||||||
|
records.Add(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RebuildRelationIndexes()
|
||||||
|
{
|
||||||
|
ChildEventsByParent.Clear();
|
||||||
|
ChildEventsByChild.Clear();
|
||||||
|
foreach (NarrativeEventRecord record in Events.Values)
|
||||||
|
{
|
||||||
|
Index(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ public static class LifeSagaPanel
|
||||||
for (int i = 0; i < model.Cast.Count; i++)
|
for (int i = 0; i < model.Cast.Count; i++)
|
||||||
{
|
{
|
||||||
LifeSagaCastMember member = model.Cast[i];
|
LifeSagaCastMember member = model.Cast[i];
|
||||||
if (member == null || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId))
|
if (member == null || member.UnitId == 0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -259,24 +259,6 @@ public static class LifeSagaPanel
|
||||||
float y = Pad;
|
float y = Pad;
|
||||||
float yMax = BodyHeightFixed - Pad;
|
float yMax = BodyHeightFixed - Pad;
|
||||||
|
|
||||||
// The Beat can update after the model binds in the same frame. Recheck by
|
|
||||||
// structured ids; display-name substring matching removed legitimate Cast
|
|
||||||
// from a different hovered MC and collided on short names.
|
|
||||||
for (int i = 0; i < CastSlots.Length; i++)
|
|
||||||
{
|
|
||||||
CastSlot slot = CastSlots[i];
|
|
||||||
if (slot?.Root == null || !slot.Root.activeSelf || slot.Name == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CastDuplicatesCurrentBeat(_boundId, slot.UnitId))
|
|
||||||
{
|
|
||||||
slot.Root.SetActive(false);
|
|
||||||
slot.UnitId = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int castN = 0;
|
int castN = 0;
|
||||||
LastVisibleRelationCount = 0;
|
LastVisibleRelationCount = 0;
|
||||||
for (int i = 0; i < CastSlots.Length; i++)
|
for (int i = 0; i < CastSlots.Length; i++)
|
||||||
|
|
@ -463,44 +445,23 @@ public static class LifeSagaPanel
|
||||||
slot.Root.SetActive(true);
|
slot.Root.SetActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The camera Beat already explains this exact member for this exact subject.</summary>
|
/// <summary>Harness: Cast membership is independent of the transient orange Beat.</summary>
|
||||||
private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId)
|
internal static bool HarnessProbeStableCastSnapshot(out string detail)
|
||||||
{
|
{
|
||||||
long cameraSubjectId = EventFeedUtil.SafeId(
|
var model = new LifeSagaViewModel { UnitId = 10, Name = "Parent" };
|
||||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
model.Cast.Add(new LifeSagaCastMember
|
||||||
return ShouldDeduplicateCast(
|
{
|
||||||
panelSubjectId,
|
UnitId = 20,
|
||||||
castMemberId,
|
Name = "Child",
|
||||||
cameraSubjectId,
|
Relation = "Child",
|
||||||
WatchCaption.VisibleReasonRelatedId,
|
Alive = true
|
||||||
!string.IsNullOrEmpty(WatchCaption.VisibleBeatLine));
|
});
|
||||||
}
|
string before = Fingerprint(model);
|
||||||
|
string after = Fingerprint(model);
|
||||||
private static bool ShouldDeduplicateCast(
|
bool pass = before == after
|
||||||
long panelSubjectId,
|
&& model.Cast.Count == 1
|
||||||
long castMemberId,
|
&& model.Cast[0].UnitId == 20;
|
||||||
long cameraSubjectId,
|
detail = $"pass={pass} fingerprintStable={before == after} cast={model.Cast.Count} member={model.Cast[0].UnitId}";
|
||||||
long beatRelatedId,
|
|
||||||
bool hasVisibleBeat)
|
|
||||||
{
|
|
||||||
return hasVisibleBeat
|
|
||||||
&& panelSubjectId != 0
|
|
||||||
&& castMemberId != 0
|
|
||||||
&& cameraSubjectId == panelSubjectId
|
|
||||||
&& beatRelatedId == castMemberId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Harness: structured Cast dedupe truth table without display-name parsing.</summary>
|
|
||||||
internal static bool HarnessProbeScopedCastDedup(out string detail)
|
|
||||||
{
|
|
||||||
bool sameSubjectExact = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: true);
|
|
||||||
bool otherHoveredSubject = ShouldDeduplicateCast(11, 20, 10, 20, hasVisibleBeat: true);
|
|
||||||
bool differentMember = ShouldDeduplicateCast(10, 21, 10, 20, hasVisibleBeat: true);
|
|
||||||
bool noBeat = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: false);
|
|
||||||
bool pass = sameSubjectExact && !otherHoveredSubject && !differentMember && !noBeat;
|
|
||||||
detail =
|
|
||||||
$"pass={pass} sameExact={sameSubjectExact} otherHover={otherHoveredSubject} "
|
|
||||||
+ $"differentMember={differentMember} noBeat={noBeat}";
|
|
||||||
return pass;
|
return pass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,9 +529,7 @@ public static class LifeSagaPanel
|
||||||
private static string Fingerprint(LifeSagaViewModel model)
|
private static string Fingerprint(LifeSagaViewModel model)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(192);
|
var sb = new StringBuilder(192);
|
||||||
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine)
|
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine);
|
||||||
.Append('|').Append(WatchCaption.VisibleBeatLine)
|
|
||||||
.Append('|').Append(WatchCaption.VisibleReasonRelatedId);
|
|
||||||
for (int i = 0; i < model.Cast.Count; i++)
|
for (int i = 0; i < model.Cast.Count; i++)
|
||||||
{
|
{
|
||||||
var c = model.Cast[i];
|
var c = model.Cast[i];
|
||||||
|
|
|
||||||
|
|
@ -574,9 +574,36 @@ public static class LifeSagaPresentation
|
||||||
{
|
{
|
||||||
AddObserved(f.Other, SagaProse.CastLabelLive("once loved"), "observed");
|
AddObserved(f.Other, SagaProse.CastLabelLive("once loved"), "observed");
|
||||||
}
|
}
|
||||||
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
|
else if (f.Kind == LifeSagaFactKind.ParentChild)
|
||||||
{
|
{
|
||||||
AddObserved(f.Other, SagaProse.CastLabelLive("child"), "observed");
|
string relation = LifeSagaMemory.IsParentPerspective(f) ? "child" : "parent";
|
||||||
|
AddObserved(f.Other, SagaProse.CastLabelLive(relation), "observed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmed birth events are the canonical directional fallback. WorldBox
|
||||||
|
// family membership is broader than parenthood, and its getChildren view
|
||||||
|
// can be incomplete for an already-living actor.
|
||||||
|
if (model.Cast.Count < LifeSagaPanel.MaxCastCandidates)
|
||||||
|
{
|
||||||
|
IReadOnlyList<NarrativeEventRecord> children = NarrativeEventStore.ChildrenOf(model.UnitId);
|
||||||
|
for (int i = 0; i < children.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++)
|
||||||
|
{
|
||||||
|
NarrativeEventRecord record = children[i];
|
||||||
|
if (record != null)
|
||||||
|
{
|
||||||
|
AddObserved(record.Other, SagaProse.CastLabelLive("child"), "observed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<NarrativeEventRecord> parents = NarrativeEventStore.ParentsOf(model.UnitId);
|
||||||
|
for (int i = 0; i < parents.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++)
|
||||||
|
{
|
||||||
|
NarrativeEventRecord record = parents[i];
|
||||||
|
if (record != null)
|
||||||
|
{
|
||||||
|
AddObserved(record.Subject, SagaProse.CastLabelLive("parent"), "observed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ public static class LifeSagaRoster
|
||||||
SpeciesCountCache.Clear();
|
SpeciesCountCache.Clear();
|
||||||
McOrbitCache.Clear();
|
McOrbitCache.Clear();
|
||||||
NarrativeChallengerScratch.Clear();
|
NarrativeChallengerScratch.Clear();
|
||||||
|
ActorRelation.ClearObservedRelations();
|
||||||
_nextScanAt = 0f;
|
_nextScanAt = 0f;
|
||||||
_nextNarrativeAdmissionAt = 0f;
|
_nextNarrativeAdmissionAt = 0f;
|
||||||
_roleCacheAt = -999f;
|
_roleCacheAt = -999f;
|
||||||
|
|
@ -908,6 +909,7 @@ public static class LifeSagaRoster
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ActorRelation.ObserveParentLinks(actor);
|
||||||
long id = EventFeedUtil.SafeId(actor);
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
if (id == 0 || ScanSeen.Contains(id))
|
if (id == 0 || ScanSeen.Contains(id))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,13 @@ facts newer than the loaded world time, and rebuilds live actor bindings without
|
||||||
events as fresh camera developments. Camera focus, active episodes, cooldowns, and session time are
|
events as fresh camera developments. Camera focus, active episodes, cooldowns, and session time are
|
||||||
never serialized.
|
never serialized.
|
||||||
|
|
||||||
|
Cast membership is a stable Saga snapshot. It does not disappear merely because the orange camera
|
||||||
|
beat mentions the same relative; the beat is hidden while Saga is open. Recorded parent/child facts
|
||||||
|
and indexed confirmed birth events also backfill living relatives when WorldBox's live family
|
||||||
|
enumeration is temporarily incomplete. Generic family membership is only labeled `Kin` after these
|
||||||
|
directional sources have been exhausted. For older saves, a bounded reverse lookup also recognizes
|
||||||
|
a family peer as a child when that peer's own parent data points to the displayed character.
|
||||||
|
|
||||||
Legacy is authored only by `LegacyChapterBuilder`: 3+ births consolidate into lineage, repeated
|
Legacy is authored only by `LegacyChapterBuilder`: 3+ births consolidate into lineage, repeated
|
||||||
kills consolidate into a combat chapter, and current Cast/Identity facts do not echo unchanged.
|
kills consolidate into a combat chapter, and current Cast/Identity facts do not echo unchanged.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ Recommended scenario names:
|
||||||
- `story_cross_arc_correlation`
|
- `story_cross_arc_correlation`
|
||||||
- `caption_status_banner_live`
|
- `caption_status_banner_live`
|
||||||
- `saga_relation_cache_revision`
|
- `saga_relation_cache_revision`
|
||||||
- `saga_cast_dedupe_scoped`
|
- `saga_cast_stable_snapshot`
|
||||||
- `saga_hover_max_content`
|
- `saga_hover_max_content`
|
||||||
|
|
||||||
### D3. Verification order
|
### D3. Verification order
|
||||||
|
|
@ -335,7 +335,7 @@ Suggested commit boundaries:
|
||||||
|
|
||||||
- New focused probes passed three consecutive runs each:
|
- New focused probes passed three consecutive runs each:
|
||||||
`story_cross_arc_correlation`, `caption_status_banner_live`,
|
`story_cross_arc_correlation`, `caption_status_banner_live`,
|
||||||
`saga_relation_cache_revision`, and `saga_cast_dedupe_scoped`.
|
`saga_relation_cache_revision`, and `saga_cast_stable_snapshot`.
|
||||||
- Existing story, Saga, caption, director, dossier, lifecycle, and critical-smoke scenarios
|
- Existing story, Saga, caption, director, dossier, lifecycle, and critical-smoke scenarios
|
||||||
passed individually after the implementation.
|
passed individually after the implementation.
|
||||||
- A fresh-state full regression passed **19/19 scenarios** on 2026-07-22.
|
- A fresh-state full regression passed **19/19 scenarios** on 2026-07-22.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue