Persist entity contact state in typed snapshots

This commit is contained in:
Meta Vens 2026-06-14 16:51:56 +01:00
commit 1f8a1576f8
10 changed files with 160 additions and 4 deletions

Binary file not shown.

View file

@ -233,6 +233,16 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
DockingBayAssignments = [-1],
Visibility = 12.75,
VisibilitySourceCount = 3,
Contacts =
[
new AetheriaEntityContactSnapshot
{
TargetEntityKey = entityKey.ToString(),
InfoGathered = 0.85,
Hostile = true,
Visible = true
}
],
WeaponGroups =
[
new AetheriaWeaponGroupSnapshot
@ -627,6 +637,11 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
entitySnapshot.DockingBayAssignments[0] != -1 ||
entitySnapshot.Visibility != 12.75 ||
entitySnapshot.VisibilitySourceCount != 3 ||
entitySnapshot.Contacts.Length != 1 ||
entitySnapshot.Contacts[0].TargetEntityKey != entityKey.ToString() ||
entitySnapshot.Contacts[0].InfoGathered != 0.85 ||
!entitySnapshot.Contacts[0].Hostile ||
!entitySnapshot.Contacts[0].Visible ||
entitySnapshot.WeaponGroups.Length != 1 ||
entitySnapshot.WeaponStates.Length != 1 ||
entitySnapshot.WeaponStates[0].BehaviorKind != "LockWeapon" ||

View file

@ -359,6 +359,16 @@ try
DockingBayAssignments = new[] { -1 },
Visibility = 12.75,
VisibilitySourceCount = 3,
Contacts = new[]
{
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = 0,
InfoGathered = 0.85,
Hostile = true,
Visible = true
}
},
WeaponGroups = new[] { new[] { 0 } },
ActiveConsumables = new[]
{
@ -693,6 +703,11 @@ try
entity.DockingBayAssignments[0] == -1 &&
entity.Visibility == 12.75 &&
entity.VisibilitySourceCount == 3 &&
entity.Contacts.Length == 1 &&
entity.Contacts[0].TargetEntityKey == "global:aetheria.run_state.smoke-run.zone.0.entity.0.v1" &&
entity.Contacts[0].InfoGathered == 0.85 &&
entity.Contacts[0].Hostile &&
entity.Contacts[0].Visible &&
entity.ActiveConsumables[0].ItemKey == "aetheria.item_definition:legacy:smoke:consumable" &&
entity.ActiveConsumables[0].RemainingDuration == 3.0 &&
entity.BehaviorProgress.Length == 2 &&
@ -818,6 +833,11 @@ try
packageEntities[0].DockingBayAssignments[0] != -1 ||
packageEntities[0].Visibility != 12.75 ||
packageEntities[0].VisibilitySourceCount != 3 ||
packageEntities[0].Contacts.Count != 1 ||
packageEntities[0].Contacts[0].TargetEntityKey != "global:aetheria.run_state.smoke-run.zone.0.entity.0.v1" ||
packageEntities[0].Contacts[0].InfoGathered != 0.85 ||
!packageEntities[0].Contacts[0].Hostile ||
!packageEntities[0].Contacts[0].Visible ||
packageEntities[0].ActiveConsumables[0].ItemKey != "aetheria.item_definition:legacy:smoke:consumable" ||
packageEntities[0].BehaviorProgress.Count != 2 ||
packageEntities[0].WeaponStates.Count != 4 ||

View file

@ -460,10 +460,28 @@ public static class AetheriaRuntimeCommitLogApplier
DockingBayContents = ToCargoBays(entity.DockingBayContents),
DockingBayAssignments = ToIntArray(entity.DockingBayAssignments),
Visibility = entity.Visibility,
VisibilitySourceCount = entity.VisibilitySourceCount
VisibilitySourceCount = entity.VisibilitySourceCount,
Contacts = ToEntityContacts(entity.Contacts, runId, zoneIndex)
};
}
private static AetheriaEntityContactSnapshot[] ToEntityContacts(
IReadOnlyList<AetheriaRuntimeEntityContactCommit>? contacts,
string runId,
int zoneIndex)
{
return (contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.Where(contact => contact.TargetEntityIndex >= 0)
.Select(contact => new AetheriaEntityContactSnapshot
{
TargetEntityKey = EntityKey(runId, zoneIndex, contact.TargetEntityIndex).ToString(),
InfoGathered = contact.InfoGathered,
Hostile = contact.Hostile,
Visible = contact.Visible
})
.ToArray();
}
private static AetheriaBehaviorStateSnapshot[] ToBehaviorStates(
IReadOnlyList<AetheriaRuntimeBehaviorStateCommit>? states)
{

View file

@ -509,6 +509,26 @@ public sealed class AetheriaEntitySnapshot
[Key(28)]
public int VisibilitySourceCount { get; set; }
[Key(29)]
public AetheriaEntityContactSnapshot[] Contacts { get; set; } = [];
}
[MessagePackObject]
public sealed class AetheriaEntityContactSnapshot
{
[Key(0)]
[CultReference(typeof(AetheriaEntitySnapshot))]
public string TargetEntityKey { get; set; } = "";
[Key(1)]
public double InfoGathered { get; set; }
[Key(2)]
public bool Hostile { get; set; }
[Key(3)]
public bool Visible { get; set; }
}
[MessagePackObject]

View file

@ -787,6 +787,7 @@ public class ActionGameManager : MonoBehaviour
.ToArray() ?? Array.Empty<int>(),
Visibility = entity.Visibility,
VisibilitySourceCount = entity.VisibilitySources.Count,
Contacts = ProjectEntityContacts(zone, entity),
ChildEntityIndices = entity.Children?
.Select(child => zone.Entities.IndexOf(child))
.Where(index => index >= 0)
@ -802,6 +803,25 @@ public class ActionGameManager : MonoBehaviour
};
}
private static AetheriaRuntimeEntityContactCommit[] ProjectEntityContacts(Zone zone, Entity entity)
{
return entity.EntityInfoGathered
.Select(contact =>
{
var targetIndex = zone.Entities.IndexOf(contact.Key);
entity.EntityHostility.TryGetValue(contact.Key, out var hostile);
return new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = targetIndex,
InfoGathered = contact.Value,
Hostile = hostile,
Visible = entity.VisibleEntities.Contains(contact.Key)
};
})
.Where(contact => contact.TargetEntityIndex >= 0)
.ToArray();
}
private static AetheriaRuntimeCargoBayLoadoutCommit[] ProjectRuntimeCargoBays<TBay>(IEnumerable<TBay> bays)
where TBay : EquippedCargoBay
{

View file

@ -788,7 +788,8 @@ namespace GameCult.Aetheria.State.Unity
IReadOnlyList<AetheriaRuntimeCargoBayLoadoutSnapshot> dockingBayContents,
IReadOnlyList<int> dockingBayAssignments,
double visibility,
int visibilitySourceCount)
int visibilitySourceCount,
IReadOnlyList<AetheriaRuntimeEntityContactSnapshot> contacts)
{
Name = name;
Kind = kind;
@ -823,6 +824,7 @@ namespace GameCult.Aetheria.State.Unity
DockingBayAssignments = dockingBayAssignments;
Visibility = visibility;
VisibilitySourceCount = visibilitySourceCount;
Contacts = contacts;
}
public string Name { get; }
@ -858,6 +860,23 @@ namespace GameCult.Aetheria.State.Unity
public IReadOnlyList<int> DockingBayAssignments { get; }
public double Visibility { get; }
public int VisibilitySourceCount { get; }
public IReadOnlyList<AetheriaRuntimeEntityContactSnapshot> Contacts { get; }
}
public sealed class AetheriaRuntimeEntityContactSnapshot
{
public AetheriaRuntimeEntityContactSnapshot(string targetEntityKey, double infoGathered, bool hostile, bool visible)
{
TargetEntityKey = targetEntityKey;
InfoGathered = infoGathered;
Hostile = hostile;
Visible = visible;
}
public string TargetEntityKey { get; }
public double InfoGathered { get; }
public bool Hostile { get; }
public bool Visible { get; }
}
public sealed class AetheriaRuntimeActionBarBindingSnapshot

View file

@ -537,7 +537,8 @@ namespace GameCult.Aetheria.State.Unity
var dockingBayAssignments = ReadFieldInt32Array(ref reader, fields, 26);
var visibility = ReadFieldDouble(ref reader, fields, 27);
var visibilitySourceCount = ReadFieldInt32(ref reader, fields, 28);
SkipRemaining(ref reader, fields, 29);
var contacts = ReadFieldEntityContacts(ref reader, fields, 29);
SkipRemaining(ref reader, fields, 30);
return new AetheriaRuntimeEntitySnapshot(
name,
kind,
@ -571,7 +572,27 @@ namespace GameCult.Aetheria.State.Unity
dockingBayContents,
dockingBayAssignments,
visibility,
visibilitySourceCount);
visibilitySourceCount,
contacts);
}
private static IReadOnlyList<AetheriaRuntimeEntityContactSnapshot> ReadFieldEntityContacts(ref MessagePackReader reader, int fields, int index)
{
if (index >= fields) return Array.Empty<AetheriaRuntimeEntityContactSnapshot>();
var count = reader.ReadArrayHeader();
var contacts = new AetheriaRuntimeEntityContactSnapshot[count];
for (var contact = 0; contact < count; contact++)
{
var contactFields = reader.ReadArrayHeader();
var targetEntityKey = ReadFieldString(ref reader, contactFields, 0);
var infoGathered = ReadFieldDouble(ref reader, contactFields, 1);
var hostile = ReadFieldBool(ref reader, contactFields, 2);
var visible = ReadFieldBool(ref reader, contactFields, 3);
SkipRemaining(ref reader, contactFields, 4);
contacts[contact] = new AetheriaRuntimeEntityContactSnapshot(targetEntityKey, infoGathered, hostile, visible);
}
return contacts;
}
private static Vector2Value ReadFieldVector2(ref MessagePackReader reader, int fields, int index)

View file

@ -585,6 +585,25 @@ namespace GameCult.Aetheria.State.Unity
[Key(33)]
public int VisibilitySourceCount { get; set; }
[Key(34)]
public IReadOnlyList<AetheriaRuntimeEntityContactCommit> Contacts { get; set; } = Array.Empty<AetheriaRuntimeEntityContactCommit>();
}
[MessagePackObject]
public sealed class AetheriaRuntimeEntityContactCommit
{
[Key(0)]
public int TargetEntityIndex { get; set; } = -1;
[Key(1)]
public double InfoGathered { get; set; }
[Key(2)]
public bool Hostile { get; set; }
[Key(3)]
public bool Visible { get; set; }
}
[MessagePackObject]

View file

@ -153,6 +153,7 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
They also carry public runtime session state: velocity, target entity
reference, active flag, heatsink toggle, shutdown override, tractor power,
heatstroke/hypothermia accumulators, aggregate visibility/source-count state,
contact rows for gathered information, hostility, and visible classification,
and active consumable item/timer rows.
Entity equipment, cargo-bay, and docking-bay slot rows preserve item-instance
facts needed for restore: item key, quality, durability, and quantity. Those
@ -850,6 +851,9 @@ First Aetheria surfaces to publish:
- Done: preserve aggregate entity visibility and visibility-source count in
typed entity snapshots, so `Entity.VisibilitySources` no longer hides
untyped session pressure.
- Done: preserve entity contact rows for gathered info, hostility, and
visible classification, so detection/contact state is typed snapshot data
instead of only reactive runtime collections.
- Remaining: add typed documents/mappers for runtime object graphs,
typed behavior factory construction, remaining behavior-private state not
covered by progress, weapon, sensor, radiator, reactor, or capacitor rows,