diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 6ab6da796..b644a2a7a 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -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() { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs index 51a7c66f6..3bf623257 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs @@ -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(); + + var entities = zone.Entities ?? Array.Empty(); + var observer = entities.FirstOrDefault(entity => + entity != null && entity.EntityIndex == observerEntityIndex); + if (observer == null) + return Array.Empty(); + + var sources = new List { observer }; + var dockParent = entities.FirstOrDefault(entity => entity != null && + (entity.DockingBayAssignments ?? Array.Empty()).Contains(observerEntityIndex)); + if (dockParent != null && dockParent.EntityIndex != observerEntityIndex) + sources.Add(dockParent); + + var contacts = new Dictionary(); + foreach (var source in sources) + foreach (var contact in source.Contacts ?? Array.Empty()) + { + 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) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs index c7d618379..bb9326dcf 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs @@ -51,11 +51,10 @@ namespace GameCult.Aetheria.State.Verse var dockParent = (zone?.Entities ?? Array.Empty()) .FirstOrDefault(entity => entity != null && controlled != null && (entity.DockingBayAssignments ?? Array.Empty()).Contains(controlled.EntityIndex)); - var visibleContacts = (controlled?.Contacts ?? Array.Empty()) - .Concat(dockParent?.Contacts ?? Array.Empty()); - 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) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs index 01ee7b013..5fa9121ff 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs @@ -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()) .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, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs index cfdb41b75..79b19da67 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs @@ -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")] diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 4bce7cec7..80f7758bf 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -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. diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 7e428715c..0061a8288 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -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 |