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>
|
||||
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)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
@ -154,13 +218,14 @@ public static class ActorRelation
|
|||
yield break;
|
||||
}
|
||||
|
||||
var seen = new HashSet<long>();
|
||||
int yielded = 0;
|
||||
IEnumerable children = null;
|
||||
try
|
||||
{
|
||||
MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes);
|
||||
if (getChildren != null)
|
||||
if (GetChildrenMethod != null)
|
||||
{
|
||||
children = getChildren.Invoke(actor, null) as IEnumerable;
|
||||
children = GetChildrenMethod.Invoke(actor, null) as IEnumerable;
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
@ -168,21 +233,75 @@ public static class ActorRelation
|
|||
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;
|
||||
foreach (object raw in children)
|
||||
if (ObservedChildrenByParent.TryGetValue(
|
||||
EventFeedUtil.SafeId(actor),
|
||||
out List<long> observedChildren))
|
||||
{
|
||||
Actor child = raw as Actor ?? ResolveActor(raw);
|
||||
if (child == null || !child.isAlive())
|
||||
for (int i = 0; i < observedChildren.Count; i++)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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++;
|
||||
if (yielded >= max)
|
||||
{
|
||||
|
|
@ -199,7 +318,20 @@ public static class ActorRelation
|
|||
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)
|
||||
{
|
||||
parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids");
|
||||
|
|
@ -366,39 +498,7 @@ public static class ActorRelation
|
|||
|
||||
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;
|
||||
return EventFeedUtil.FindAliveById(id);
|
||||
}
|
||||
|
||||
private static Actor ResolveActorId(object raw)
|
||||
|
|
@ -481,7 +581,12 @@ public static class ActorRelation
|
|||
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)
|
||||
{
|
||||
return method.Invoke(instance, null);
|
||||
|
|
|
|||
|
|
@ -1435,9 +1435,20 @@ public static class AgentHarness
|
|||
if (child != null && parent != null && child.isAlive() && parent.isAlive()
|
||||
&& 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;
|
||||
detail = $"parent={SafeName(parent)} child={SafeName(child)}";
|
||||
detail = $"parent={SafeName(parent)} child={SafeName(child)} slot={(parent2 ? 2 : 1)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -3683,14 +3694,26 @@ public static class AgentHarness
|
|||
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++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
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":
|
||||
LifeSagaRoster.Clear();
|
||||
LifeSagaMemory.Clear();
|
||||
|
|
|
|||
|
|
@ -164,6 +164,11 @@ public static class RelationshipEventPatches
|
|||
|
||||
private static void EmitAddChild(Actor parent, Actor child, bool increaseChildren)
|
||||
{
|
||||
if (increaseChildren && EventOutcome.IsLiving(parent) && EventOutcome.IsLiving(child))
|
||||
{
|
||||
ActorRelation.RememberParentChild(parent, child);
|
||||
}
|
||||
|
||||
EventObservation.Confirm(
|
||||
"setParent",
|
||||
"add_child",
|
||||
|
|
|
|||
|
|
@ -266,8 +266,10 @@ internal static class HarnessScenarios
|
|||
return SagaSessionReset();
|
||||
case "saga_relation_cache_revision":
|
||||
return SagaRelationCacheRevision();
|
||||
case "saga_cast_dedupe_scoped":
|
||||
return SagaCastDedupeScoped();
|
||||
case "saga_cast_stable_snapshot":
|
||||
return SagaCastStableSnapshot();
|
||||
case "saga_live_child_reverse":
|
||||
return SagaLiveChildReverse();
|
||||
case "saga_hover_read_pause":
|
||||
return SagaHoverReadPause();
|
||||
case "saga_admit_roles":
|
||||
|
|
@ -488,7 +490,7 @@ internal static class HarnessScenarios
|
|||
Nested("reg_narrative_replay", "narrative_editorial_replay"),
|
||||
Nested("reg_caption_status_banner", "caption_status_banner_live"),
|
||||
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_crisis_hygiene", "story_crisis_hygiene"),
|
||||
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>
|
||||
{
|
||||
Step("scds0", "wait_world"),
|
||||
Step("scds1", "saga_cast_dedupe_probe"),
|
||||
Step("scds2", "assert", expect: "no_bad"),
|
||||
Step("scds0", "dismiss_windows"),
|
||||
Step("scds1", "wait_world"),
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
private static List<HarnessCommand> CaptionCharacterBeat()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -281,6 +281,10 @@ public static class NarrativeEventRecorder
|
|||
NarrativeEventRecord existing = NarrativeEventStore.Get(id);
|
||||
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 =
|
||||
string.IsNullOrEmpty(eventId)
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ public static class NarrativeEventStore
|
|||
{
|
||||
private static readonly Dictionary<string, NarrativeEventRecord> Events =
|
||||
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 void Clear()
|
||||
{
|
||||
Events.Clear();
|
||||
ChildEventsByParent.Clear();
|
||||
ChildEventsByChild.Clear();
|
||||
}
|
||||
|
||||
public static NarrativeEventRecord Get(string id)
|
||||
|
|
@ -36,6 +42,7 @@ public static class NarrativeEventStore
|
|||
}
|
||||
|
||||
Events[record.Id] = record;
|
||||
Index(record);
|
||||
NarrativePersistence.MarkDirty();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -50,9 +57,24 @@ public static class NarrativeEventStore
|
|||
}
|
||||
|
||||
Events[record.Id] = record;
|
||||
Index(record);
|
||||
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>
|
||||
/// Keep the event side of the runtime graph aligned with retained developments.
|
||||
/// 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);
|
||||
}
|
||||
for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]);
|
||||
if (remove.Count > 0)
|
||||
{
|
||||
RebuildRelationIndexes();
|
||||
}
|
||||
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++)
|
||||
{
|
||||
LifeSagaCastMember member = model.Cast[i];
|
||||
if (member == null || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId))
|
||||
if (member == null || member.UnitId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -259,24 +259,6 @@ public static class LifeSagaPanel
|
|||
float y = 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;
|
||||
LastVisibleRelationCount = 0;
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
|
|
@ -463,44 +445,23 @@ public static class LifeSagaPanel
|
|||
slot.Root.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>The camera Beat already explains this exact member for this exact subject.</summary>
|
||||
private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId)
|
||||
/// <summary>Harness: Cast membership is independent of the transient orange Beat.</summary>
|
||||
internal static bool HarnessProbeStableCastSnapshot(out string detail)
|
||||
{
|
||||
long cameraSubjectId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
return ShouldDeduplicateCast(
|
||||
panelSubjectId,
|
||||
castMemberId,
|
||||
cameraSubjectId,
|
||||
WatchCaption.VisibleReasonRelatedId,
|
||||
!string.IsNullOrEmpty(WatchCaption.VisibleBeatLine));
|
||||
}
|
||||
|
||||
private static bool ShouldDeduplicateCast(
|
||||
long panelSubjectId,
|
||||
long castMemberId,
|
||||
long cameraSubjectId,
|
||||
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}";
|
||||
var model = new LifeSagaViewModel { UnitId = 10, Name = "Parent" };
|
||||
model.Cast.Add(new LifeSagaCastMember
|
||||
{
|
||||
UnitId = 20,
|
||||
Name = "Child",
|
||||
Relation = "Child",
|
||||
Alive = true
|
||||
});
|
||||
string before = Fingerprint(model);
|
||||
string after = Fingerprint(model);
|
||||
bool pass = before == after
|
||||
&& model.Cast.Count == 1
|
||||
&& model.Cast[0].UnitId == 20;
|
||||
detail = $"pass={pass} fingerprintStable={before == after} cast={model.Cast.Count} member={model.Cast[0].UnitId}";
|
||||
return pass;
|
||||
}
|
||||
|
||||
|
|
@ -568,9 +529,7 @@ public static class LifeSagaPanel
|
|||
private static string Fingerprint(LifeSagaViewModel model)
|
||||
{
|
||||
var sb = new StringBuilder(192);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine)
|
||||
.Append('|').Append(WatchCaption.VisibleBeatLine)
|
||||
.Append('|').Append(WatchCaption.VisibleReasonRelatedId);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine);
|
||||
for (int i = 0; i < model.Cast.Count; i++)
|
||||
{
|
||||
var c = model.Cast[i];
|
||||
|
|
|
|||
|
|
@ -574,9 +574,36 @@ public static class LifeSagaPresentation
|
|||
{
|
||||
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();
|
||||
McOrbitCache.Clear();
|
||||
NarrativeChallengerScratch.Clear();
|
||||
ActorRelation.ClearObservedRelations();
|
||||
_nextScanAt = 0f;
|
||||
_nextNarrativeAdmissionAt = 0f;
|
||||
_roleCacheAt = -999f;
|
||||
|
|
@ -908,6 +909,7 @@ public static class LifeSagaRoster
|
|||
continue;
|
||||
}
|
||||
|
||||
ActorRelation.ObserveParentLinks(actor);
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
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
|
||||
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
|
||||
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`
|
||||
- `caption_status_banner_live`
|
||||
- `saga_relation_cache_revision`
|
||||
- `saga_cast_dedupe_scoped`
|
||||
- `saga_cast_stable_snapshot`
|
||||
- `saga_hover_max_content`
|
||||
|
||||
### D3. Verification order
|
||||
|
|
@ -335,7 +335,7 @@ Suggested commit boundaries:
|
|||
|
||||
- New focused probes passed three consecutive runs each:
|
||||
`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
|
||||
passed individually after the implementation.
|
||||
- A fresh-state full regression passed **19/19 scenarios** on 2026-07-22.
|
||||
|
|
|
|||
Loading…
Reference in a new issue