Share dock parent sensor picture

This commit is contained in:
Meta Vens 2026-07-13 15:57:12 +01:00
commit cadddb4c87
7 changed files with 208 additions and 9 deletions

View file

@ -2274,6 +2274,96 @@ public class DaemonRuntimeDocumentTests
Assert.AreEqual(0.8, arrayShip.Visibility, 0.0001);
}
[Test]
public void DockedPilotConsumesStationContactPictureThroughEveAndSoa()
{
var pilot = new AetheriaRuntimeEntitySnapshotCommit
{
EntityIndex = 7, Kind = "ship", FactionKey = "player", IsActive = true,
Contacts = new[]
{
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = 3, InfoGathered = 0.2, Hostile = true, Visible = false
}
}
};
var station = new AetheriaRuntimeEntitySnapshotCommit
{
EntityIndex = 2, Kind = "station", FactionKey = "player", IsActive = true,
DockingBayAssignments = new[] { 7 },
Contacts = new[]
{
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = 3, InfoGathered = 0.9, Visible = true
},
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = 4, InfoGathered = 0.7, Visible = true
}
}
};
var unrelatedObserver = new AetheriaRuntimeEntitySnapshotCommit
{
EntityIndex = 8, Kind = "ship", IsActive = true,
Contacts = new[]
{
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = 5, InfoGathered = 1, Visible = true
}
}
};
var zone = new AetheriaRuntimeZoneSnapshotCommit
{
ZoneIndex = 2,
Entities = new[]
{
station,
pilot,
unrelatedObserver,
new AetheriaRuntimeEntitySnapshotCommit { EntityIndex = 3, IsActive = true },
new AetheriaRuntimeEntitySnapshotCommit { EntityIndex = 4, IsActive = true },
new AetheriaRuntimeEntitySnapshotCommit { EntityIndex = 5, IsActive = true }
}
};
var frame = AetheriaRuntimeDaemonFrameDocument.Create(
new AetheriaRuntimeRunCheckpointCommit
{
RunId = "docked-sensors",
CurrentZoneIndex = 2,
CurrentEntityKey = "global:aetheria.run_state.docked-sensors.zone.2.entity.7.v1",
Zones = new[] { zone }
},
"aetheria-daemon",
"session-docked-sensors",
51,
1,
0.02);
var contacts = AetheriaRuntimeGameDocuments.ZoneContacts(frame);
var pilotContacts = contacts.Contacts
.Where(contact => contact.ObserverEntityIndex == 7)
.OrderBy(contact => contact.TargetEntityIndex)
.ToArray();
Assert.AreEqual(2, pilotContacts.Length);
Assert.AreEqual(3, pilotContacts[0].TargetEntityIndex);
Assert.AreEqual(0.9, pilotContacts[0].InfoGathered, 0.0001);
Assert.IsTrue(pilotContacts[0].Visible);
Assert.IsTrue(pilotContacts[0].Hostile, "Contact facts from both sensor sources must merge.");
Assert.AreEqual(2, pilotContacts[0].PrimarySensorSourceEntityIndex);
Assert.AreEqual(4, pilotContacts[1].TargetEntityIndex);
Assert.AreEqual(2, pilotContacts[1].PrimarySensorSourceEntityIndex);
Assert.IsFalse(pilotContacts.Any(contact => contact.TargetEntityIndex == 5),
"An unrelated observer's contacts must not enter the pilot picture.");
var statePath = Path.Combine(Path.GetTempPath(), "aetheria-docked-sensors", Path.GetRandomFileName(), "state.cc");
var view = AetheriaRuntimeDaemonSoaFramePublisher.BuildCurrentZoneEntities(statePath, frame);
CollectionAssert.AreEquivalent(new[] { 2, 3, 4, 7 },
view.Identities.Select(identity => identity.EntityIndex).ToArray());
}
[Test]
public void TerminusPilotTractorScoopsCargoFromTargetedWreck()
{

View file

@ -676,8 +676,92 @@ namespace GameCult.Aetheria.State.Verse
public double DepthRange { get; }
}
public readonly struct AetheriaRuntimeEffectiveContact
{
public AetheriaRuntimeEffectiveContact(
int observerEntityIndex,
int primarySensorSourceEntityIndex,
AetheriaRuntimeEntityContactCommit contact)
{
ObserverEntityIndex = observerEntityIndex;
PrimarySensorSourceEntityIndex = primarySensorSourceEntityIndex;
Contact = contact ?? throw new ArgumentNullException(nameof(contact));
}
public int ObserverEntityIndex { get; }
public int PrimarySensorSourceEntityIndex { get; }
public AetheriaRuntimeEntityContactCommit Contact { get; }
}
public static class AetheriaRuntimeDaemonRenderQueries
{
public static AetheriaRuntimeEffectiveContact[] QueryEffectiveContacts(
AetheriaRuntimeZoneSnapshotCommit? zone,
int observerEntityIndex)
{
if (zone == null || observerEntityIndex < 0)
return Array.Empty<AetheriaRuntimeEffectiveContact>();
var entities = zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>();
var observer = entities.FirstOrDefault(entity =>
entity != null && entity.EntityIndex == observerEntityIndex);
if (observer == null)
return Array.Empty<AetheriaRuntimeEffectiveContact>();
var sources = new List<AetheriaRuntimeEntitySnapshotCommit> { observer };
var dockParent = entities.FirstOrDefault(entity => entity != null &&
(entity.DockingBayAssignments ?? Array.Empty<int>()).Contains(observerEntityIndex));
if (dockParent != null && dockParent.EntityIndex != observerEntityIndex)
sources.Add(dockParent);
var contacts = new Dictionary<int, AetheriaRuntimeEffectiveContact>();
foreach (var source in sources)
foreach (var contact in source.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
{
if (contact == null || contact.TargetEntityIndex < 0)
continue;
if (!contacts.TryGetValue(contact.TargetEntityIndex, out var previous))
{
contacts.Add(contact.TargetEntityIndex, Effective(observerEntityIndex, source.EntityIndex, contact));
continue;
}
var previousContact = previous.Contact;
var sourceWins = contact.InfoGathered > previousContact.InfoGathered ||
(contact.InfoGathered == previousContact.InfoGathered && contact.Visible && !previousContact.Visible);
contacts[contact.TargetEntityIndex] = new AetheriaRuntimeEffectiveContact(
observerEntityIndex,
sourceWins ? source.EntityIndex : previous.PrimarySensorSourceEntityIndex,
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = contact.TargetEntityIndex,
InfoGathered = Math.Max(previousContact.InfoGathered, contact.InfoGathered),
Hostile = previousContact.Hostile || contact.Hostile,
Visible = previousContact.Visible || contact.Visible
});
}
return contacts.Values
.OrderBy(contact => contact.Contact.TargetEntityIndex)
.ToArray();
}
private static AetheriaRuntimeEffectiveContact Effective(
int observerEntityIndex,
int primarySensorSourceEntityIndex,
AetheriaRuntimeEntityContactCommit contact) =>
new AetheriaRuntimeEffectiveContact(
observerEntityIndex,
primarySensorSourceEntityIndex,
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = contact.TargetEntityIndex,
InfoGathered = contact.InfoGathered,
Hostile = contact.Hostile,
Visible = contact.Visible
});
public static double ResolveZoneRenderRadius(
AetheriaRuntimeZoneSnapshotCommit? zone,
double fallbackRadius)

View file

@ -51,11 +51,10 @@ namespace GameCult.Aetheria.State.Verse
var dockParent = (zone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.FirstOrDefault(entity => entity != null && controlled != null &&
(entity.DockingBayAssignments ?? Array.Empty<int>()).Contains(controlled.EntityIndex));
var visibleContacts = (controlled?.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.Concat(dockParent?.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>());
var visibleEntityIndices = visibleContacts
.Where(contact => contact != null && contact.Visible)
.Select(contact => contact.TargetEntityIndex)
var visibleEntityIndices = AetheriaRuntimeDaemonRenderQueries
.QueryEffectiveContacts(zone, controlled?.EntityIndex ?? -1)
.Where(contact => contact.Contact.Visible)
.Select(contact => contact.Contact.TargetEntityIndex)
.Append(controlled?.EntityIndex ?? -1)
.ToHashSet();
if (dockParent != null)

View file

@ -481,6 +481,7 @@ namespace GameCult.Aetheria.State.Verse
var entityMap = entities
.Where(entity => entity != null && entity.EntityIndex >= 0)
.ToDictionary(entity => entity.EntityIndex, entity => entity);
var currentEntityIndex = TryParseEntityIndex(context.Run.CurrentEntityKey);
return new AetheriaRuntimeZoneContactsDocument
{
@ -497,12 +498,25 @@ namespace GameCult.Aetheria.State.Verse
.Select(entity => ToZoneTargetRow(entity, entityMap[entity.TargetEntityIndex]))
.ToArray(),
Contacts = entities
.Where(entity => entity != null)
.Where(entity => entity != null && entity.EntityIndex != currentEntityIndex)
.SelectMany(entity => (entity.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.Where(contact => contact != null &&
contact.TargetEntityIndex >= 0 &&
entityMap.ContainsKey(contact.TargetEntityIndex))
.Select(contact => ProjectZoneContactRow(entity, entityMap[contact.TargetEntityIndex], contact)))
.Select(contact => ProjectZoneContactRow(
entity.EntityIndex,
entity.EntityIndex,
entity,
entityMap[contact.TargetEntityIndex],
contact)))
.Concat(AetheriaRuntimeDaemonRenderQueries.QueryEffectiveContacts(context.Zone, currentEntityIndex)
.Where(effective => entityMap.ContainsKey(effective.Contact.TargetEntityIndex))
.Select(effective => ProjectZoneContactRow(
effective.ObserverEntityIndex,
effective.PrimarySensorSourceEntityIndex,
entityMap[currentEntityIndex],
entityMap[effective.Contact.TargetEntityIndex],
effective.Contact)))
.ToArray()
};
}
@ -588,6 +602,8 @@ namespace GameCult.Aetheria.State.Verse
}
private static AetheriaRuntimeZoneContactRow ProjectZoneContactRow(
int observerEntityIndex,
int primarySensorSourceEntityIndex,
AetheriaRuntimeEntitySnapshotCommit observer,
AetheriaRuntimeEntitySnapshotCommit target,
AetheriaRuntimeEntityContactCommit contact)
@ -597,7 +613,8 @@ namespace GameCult.Aetheria.State.Verse
var deltaZ = target.PositionZ - observer.PositionZ;
return new AetheriaRuntimeZoneContactRow
{
ObserverEntityIndex = observer.EntityIndex,
ObserverEntityIndex = observerEntityIndex,
PrimarySensorSourceEntityIndex = primarySensorSourceEntityIndex,
TargetEntityIndex = target.EntityIndex,
InfoGathered = contact.InfoGathered,
Hostile = contact.Hostile,

View file

@ -461,6 +461,9 @@ namespace GameCult.Aetheria.State.Verse
[Key(11)]
public double Distance { get; set; }
[Key(12)]
public int PrimarySensorSourceEntityIndex { get; set; } = -1;
}
[CultDocument("gamecult.aetheria.station_refit", "gamecult.aetheria.station_refit.v1")]

View file

@ -279,6 +279,12 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
installed equipment. Contact refresh consumes this derived reach but does
not write `Visibility`; emitted signature remains owned by thermal,
propulsion, weapons, reflectors, explicit visibility sources, and pings.
- The daemon observation query owns docked sensor sharing. A docked controlled
craft's effective contact picture merges its own contacts with the dock
parent's contacts, retains the primary sensor source on every projected row,
and resolves duplicate targets to the strongest gathered information.
Eve contact documents and SoA render bodies consume this same query; runtime
clients do not discover docking relationships or union sensor state.
`EntityConstructionBlueprint` still exists for construction/loadout projection,
but those live hull grids and session scalars are not blueprint fields and
cannot be restored by the old blueprint projector path.

View file

@ -42,7 +42,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
| Terminus loop | original game composition | daemon/Ymir/Eve | pilot surface | Aetheria.Unity | live minimum-loop proof: generic Unity discovers the provider, lowers the pilot world/assets, single-steps dock and undock while paused, buys daemon-priced station stock, single-steps Ymir salvage collection, resumes, moves, targets, fires, and presents authoritative receipts/shot outcome | destruction-created loot pickup, richer trade content, docking transitions, and sustained combat encounters |
| Starbridge | original/shared architecture intent | one daemon session | pilot + commander surfaces | Unity + Electron | commander task board and autonomous explore execution wired; Electron Eve lowering and split-target proof missing | commander orders plus four pilots and autonomous workers |
| Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; shared evaluated-stat query proven; execution families remain partial | every behavior family, ordering, resource transaction and modifier lifecycle |
| Sensors/visibility | installed sensor sensitivity, decaying signature sources, and observer information thresholds | daemon | contacts and ping events | indicators/maps | passive reach now derives from enabled installed Sensor behaviors with diminishing-return array composition regardless of entity kind; contact refresh no longer overwrites emitted visibility; the old station range survives only as obsolete snapshot compatibility | author station/ship sensor loadout budgets, then prove power/thermal shutdown, threshold decay, ping, and classification timelines |
| Sensors/visibility | installed sensor sensitivity, decaying signature sources, and observer information thresholds | daemon | contacts and ping events | indicators/maps | passive reach derives from enabled installed Sensor behaviors with diminishing-return array composition regardless of entity kind; contact refresh no longer overwrites emitted visibility; docked craft consume one daemon-derived union of craft and dock-parent contacts through both Eve rows and SoA bodies, with sensor-source provenance; the old station range survives only as obsolete snapshot compatibility | replace station/ship sensor-count shortcuts with mass/volume/power/cooling/cost allocation, then prove shutdown, threshold decay, ping, and classification timelines |
| AI/tasks | corporation task scheduler plus agent state machines | daemon | commander task board, corporation survey ledger, and shared semantic controls | Eve commander/pilot clients | point exploration, attack with optimum-range control, same-zone haul, mining/offload, resource surveying, physical station towing, persistent orbit patrol, route-aware assignment, and autonomous return home wired through shared commands; attack admission rejects missing/friendly targets and unavailable weapon groups before durable queueing; capability/priority assignment and rejection behavior proven; generic Electron commander lowering proves the worker roster | cross-zone logistics execution, cancellation, reconnect, and multi-client command transfer |
| Consumables | cargo-backed timed behavior containers | daemon | dynamic actions, active effects, and lifecycle facts | action bar/schematic | daemon atomically resolves typed metadata and concrete cargo, preserves consumed-instance quality, enforces authored stacking, evaluates effectiveness/quality, and executes ordered `EnergyDraw`, fossil-compatible no-op `Heat`, and `ItemUsage` payloads after energy tick initialization; false and unsupported payloads stop later execution with a typed fact; timers retain the fossil's exact-zero final tick; Unity remains a projection | stable effect identity, catalog revision pinning, cooldown/always-update state, stat modifiers, and remaining behavior families |