diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 94052e9db..b807098c8 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -175,6 +175,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks DockedRefitSurfacePublishesGenericCommands, FossilVelocityBehaviorsRunInDaemonFlightStep, ReflectorUsesSharedStellarLightFieldWithoutAccumulating, + SensorPingIsActorScopedEnergyGatedAndReconnectable, TractorRampsAndPullsThroughYmirWithoutTeleportingCargo, PickupIsCapacityCheckedExactlyOnceAndExpires, TradePurchaseDerivesAcceptanceFromDaemonState, @@ -461,6 +462,88 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "Reflector visibility must remain an inspectable daemon-owned source for Eve and reconnect"); } + private static void SensorPingIsActorScopedEnergyGatedAndReconnectable() + { + var sensorItem = CatalogItem("sensor-ping-array", SensorPayload( + sensitivity: 0.01, + pingBoost: 1, + pingEnergy: 5, + pingVisibility: 4, + pingRange: 200, + pingCooldown: 1, + pingDuration: 1, + pingRadiusExponent: 1)); + var capacitorItem = CatalogItem("sensor-ping-capacitor", CapacitorPayload(10, 1)); + var catalog = new AetheriaRuntimeCatalogSnapshot([sensorItem, capacitorItem], [], []); + + static AetheriaRuntimeEntitySnapshotCommit Observer(int index, string sensorKey, string capacitorKey) + { + var entity = Entity(index, 0, "player"); + entity.Equipment = + [ + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = sensorKey, Quality = 1, Durability = 1, Enabled = true } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = capacitorKey, Quality = 1, Durability = 1, Enabled = true } } + ]; + entity.BehaviorStates = + [ + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, + OwnerIndex = 1, + BehaviorIndex = 0, + BehaviorKind = "Capacitor", + CapacitorCharge = 10, + CapacitorCapacity = 10, + CapacitorEfficiency = 1 + } + ]; + return entity; + } + + var actor = Observer(0, sensorItem.ItemKey, capacitorItem.ItemKey); + var other = Observer(1, sensorItem.ItemKey, capacitorItem.ItemKey); + var target = Entity(2, 40, "raider"); + target.Visibility = 1; + var zone = new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [actor, other, target] }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "sensor-ping-smoke", + Zones = [zone] + }; + + AetheriaRuntimeSensorSimulation.StepZone( + run, zone, [actor, other, target], [], catalog, 0.25, 0.5, 0.1, 1); + AetheriaRuntimeSensorSimulation.StepZone( + run, + zone, + [actor, other, target], + [new AetheriaRuntimeDaemonSensorPingIntent { ActorEntityKey = "zone.0.entity.0" }], + catalog, + 0.25, + 0.5, + 0.1, + 2); + + var sensor = actor.BehaviorStates.Single(state => state.BehaviorKind == AetheriaRuntimeBehaviorKinds.Sensor); + var otherSensor = other.BehaviorStates.Single(state => state.BehaviorKind == AetheriaRuntimeBehaviorKinds.Sensor); + Require(sensor.Pinging && !otherSensor.Pinging, + "sensor ping intent must retain actor identity instead of activating every sensor"); + RequireEqual(2, sensor.PingedEntityCount, + "expanding ping must persist every crossed entity identity for reconnect-safe exactly-once boost"); + Require(sensor.PingedEntityIndices.SequenceEqual(new[] { 1, 2 }), + "pinged entity identities must survive in stable order rather than only an unverifiable count"); + RequireNear(5, actor.BehaviorStates.Single(state => state.BehaviorKind == "Capacitor").CapacitorCharge, + 0.000001, "accepted ping must consume its authored energy exactly once"); + Require(actor.Contacts.Single(contact => contact.TargetEntityIndex == 2).Visible, + "ping boost must cross the daemon-owned detection threshold"); + RequireNear(4, actor.StatGrids.Single(grid => grid.Name == "transient-visibility:sensor:0:0").Values.Single(), + 0.000001, "ping must publish its authored transient visibility source"); + RequireEqual(1, run.GameEvents.Count(value => value.Kind == "sensor.ping.started"), + "accepted ping must emit one provider-owned start event"); + } + private static void DockingUsesRealBaysAndFossilUndockRules() { var catalog = new AetheriaRuntimeCatalogSnapshot( @@ -1612,6 +1695,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks [ new AetheriaRuntimeBehaviorField(2, PerformanceStat(20)), new AetheriaRuntimeBehaviorField(6, PerformanceStat(150)), + new AetheriaRuntimeBehaviorField(11, PerformanceStat(8)), new AetheriaRuntimeBehaviorField(15, PerformanceStat(0)), new AetheriaRuntimeBehaviorField(16, PerformanceStat(200)), new AetheriaRuntimeBehaviorField(17, PerformanceStat(1)), @@ -1651,6 +1735,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var state = source.WeaponStates.Single(value => value.BehaviorKind == AetheriaRuntimeBehaviorKinds.InstantWeapon); Require(!state.TriggerPending, "instant weapon request must clear after the committed burst begins"); + var weaponVisibility = source.StatGrids.Single(grid => + grid.Name == "transient-visibility:weapon:equipment:0:0"); + RequireNear(8, weaponVisibility.Values.Single(), 0.000001, + "committed instant shot must publish its authored visibility as a named transient source"); + AetheriaRuntimeVisibilitySimulation.BeginTick([source], 1, 0.5); + RequireNear(8 * Math.Exp(-0.5), weaponVisibility.Values.Single(), 0.000001, + "weapon visibility must use the fossil exponential source decay rather than vanish with the effect"); var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( new AetheriaRuntimeDaemonFrameDocument { FrameId = 12, Run = run }, new AetheriaRuntimeDaemonHealthDocument(), @@ -3010,6 +3101,30 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeBehaviorField(2, PerformanceStat(efficiency)) ]); + private static AetheriaRuntimeBehaviorPayload SensorPayload( + double sensitivity = 1, + double pingBoost = 1, + double pingEnergy = 0, + double pingVisibility = 3, + double pingRange = 200, + double pingCooldown = 1, + double pingDuration = 2, + double pingRadiusExponent = 0.5) => new( + 0, AetheriaRuntimeBehaviorKinds.Sensor, 0, + [ + new AetheriaRuntimeBehaviorField(3, PerformanceStat(sensitivity)), + new AetheriaRuntimeBehaviorField(4, Curve( + new AetheriaRuntimeCurveKey(0, 1, 0, 0), + new AetheriaRuntimeCurveKey(1, 1, 0, 0))), + new AetheriaRuntimeBehaviorField(5, PerformanceStat(pingBoost)), + new AetheriaRuntimeBehaviorField(6, PerformanceStat(pingEnergy)), + new AetheriaRuntimeBehaviorField(7, PerformanceStat(pingVisibility)), + new AetheriaRuntimeBehaviorField(8, PerformanceStat(pingRange)), + new AetheriaRuntimeBehaviorField(9, PerformanceStat(pingCooldown)), + new AetheriaRuntimeBehaviorField(10, Number(pingDuration)), + new AetheriaRuntimeBehaviorField(11, Number(pingRadiusExponent)) + ]); + private static AetheriaRuntimeCatalogItem CatalogItem(string itemKey, params AetheriaRuntimeBehaviorPayload[] payloads) => new( itemKey, itemKey, "equipment", "", "", 0, 1, 1, 1, 1, 1, 1, 1, Array.Empty(), @@ -3297,7 +3412,9 @@ internal sealed class AetheriaDaemonYmirSmokeChecks } }, new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit - { ItemKey = "test-attack-capacitor", Quality = 1, Durability = 1, Enabled = true } } + { ItemKey = "test-attack-capacitor", Quality = 1, Durability = 1, Enabled = true } }, + new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "test-attack-sensor", Quality = 1, Durability = 1, Enabled = true } } ]; agent.BehaviorStates = [ @@ -3338,9 +3455,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var catalog = EquipThrusterBank( [agent], CatalogItem("test-lock-cannon", weaponPayload), - CatalogItem("test-attack-capacitor", CapacitorPayload(50, 1))); + CatalogItem("test-attack-capacitor", CapacitorPayload(50, 1)), + CatalogItem("test-attack-sensor", SensorPayload())); var target = Entity(1, 105, "raider"); target.Kind = "station"; + target.Visibility = 10; target.StatGrids = [Grid("hull", 14), Grid("shield", 0), Grid("heat", 0)]; var run = new AetheriaRuntimeRunCheckpointCommit { diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 1939d9ab8..85d58a16d 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -5182,7 +5182,7 @@ public class DaemonRuntimeDocumentTests Assert.IsTrue(result.Intents.Behaviors[0].Active); Assert.AreEqual(1, result.Intents.Consumables.Count); Assert.AreEqual("repair-gel", result.Intents.Consumables[0].ItemKey); - Assert.IsTrue(result.Intents.SensorPingRequested); + Assert.AreEqual("zone.0.entity.0", result.Intents.SensorPings.Single().ActorEntityKey); } [Test] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorKinds.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorKinds.cs index edbed90ce..1352ef76f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorKinds.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorKinds.cs @@ -19,6 +19,7 @@ namespace GameCult.Aetheria.State.Verse public const string VelocityLimit = "VelocityLimit"; public const string Reflector = "Reflector"; public const string Visibility = "Visibility"; + public const string Sensor = "Sensor"; private static readonly ISet WeaponKinds = new HashSet(StringComparer.Ordinal) { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonIntentState.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonIntentState.cs index dae4e042d..e5b5c44c1 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonIntentState.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonIntentState.cs @@ -19,11 +19,12 @@ namespace GameCult.Aetheria.State.Verse new List(); public List Wormholes { get; } = new List(); - public bool SensorPingRequested { get; set; } + public List SensorPings { get; } = + new List(); public bool HasAny => Movements.Count > 0 || - SensorPingRequested || + SensorPings.Count > 0 || WeaponGroups.Count > 0 || Behaviors.Count > 0 || Consumables.Count > 0 || @@ -47,6 +48,11 @@ namespace GameCult.Aetheria.State.Verse public bool Active { get; set; } } + public sealed class AetheriaRuntimeDaemonSensorPingIntent + { + public string ActorEntityKey { get; set; } = ""; + } + public sealed class AetheriaRuntimeDaemonBehaviorIntent { public string ActorEntityKey { get; set; } = ""; diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index 6e0417ea2..8752308fb 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -1484,10 +1484,14 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonCommandDocument command, AetheriaRuntimeDaemonIntentState intents) { - if (string.IsNullOrWhiteSpace(ResolveActorEntityKey(run, command))) + var actor = ResolveActorEntityKey(run, command); + if (string.IsNullOrWhiteSpace(actor)) return false; - intents.SensorPingRequested = true; + intents.SensorPings.Add(new AetheriaRuntimeDaemonSensorPingIntent + { + ActorEntityKey = actor + }); return true; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index da6e8da05..37188cf5a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -59,6 +59,8 @@ namespace GameCult.Aetheria.State.Verse StepPickupLifetimes(run, zone, frameId, deltaSeconds); EnsureStats(entities, settings, catalog); + AetheriaRuntimeVisibilitySimulation.BeginTick( + entities, deltaSeconds, settings.VisibilityDecay); foreach (var entity in entities) { AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog); @@ -92,9 +94,19 @@ namespace GameCult.Aetheria.State.Verse StepCombat(run, zone, entities, intents, deltaSeconds, settings, worldPhysics, catalog, frameId, simulationTimeSeconds, simulationStepIndex); AetheriaRuntimeVisibilitySimulation.StepZone(zone, entities, catalog, renderSettings); + var activeRenderSettings = renderSettings ?? AetheriaRuntimeDaemonRenderSettings.AetheriaDefault; + AetheriaRuntimeSensorSimulation.StepZone( + run, + zone, + entities, + intents?.SensorPings, + catalog, + deltaSeconds, + settings.TargetInfoDecay, + activeRenderSettings.TargetDetectionInfoThreshold, + frameId); AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); AetheriaRuntimeSurveySimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); - RefreshContacts(entities, settings, catalog); } } @@ -700,6 +712,7 @@ namespace GameCult.Aetheria.State.Verse weapon.State.Firing = true; weapon.State.CoolingDown = true; weapon.State.CooldownProgress = weapon.Cooldown; + PublishWeaponVisibility(attacker, weapon); AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog, weapon.State.OwnerIndex, weapon.Heat); ApplyWeaponWear(attacker, weapon.State, 1); @@ -1438,6 +1451,7 @@ namespace GameCult.Aetheria.State.Verse PositiveOr(behavior.EvaluateStat(6), 1), Math.Max(0, behavior.EvaluateStat(9)), Math.Max(0, behavior.EvaluateStat(10)), + Math.Max(0, behavior.EvaluateStat(11)), ReadItemKey(behavior.Payload, 12), magazineSize, PositiveOr(ReadNumber(behavior.Payload, 14), 1), PositiveOr(ReadNumber(behavior.Payload, 17), 1), @@ -1483,6 +1497,8 @@ namespace GameCult.Aetheria.State.Verse .Any(value => value != null && string.Equals(value.ShotId, shotId, StringComparison.Ordinal))) return; + PublishWeaponVisibility(attacker, weapon); + var contact = (attacker.Contacts ?? Array.Empty()) .FirstOrDefault(value => value != null && value.TargetEntityIndex == target.EntityIndex); var information = Clamp01(contact?.InfoGathered ?? 0); @@ -1583,6 +1599,18 @@ namespace GameCult.Aetheria.State.Verse frameId, settings, damage.CockpitDestroyed ? "cockpit-destroyed" : "hull-destroyed"); } + private static void PublishWeaponVisibility( + AetheriaRuntimeEntitySnapshotCommit entity, + ResolvedWeapon weapon) + { + if (weapon.Visibility <= 0) + return; + AetheriaRuntimeVisibilitySimulation.SetTransientSource( + entity, + $"weapon:{weapon.State.OwnerKind}:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}", + weapon.Visibility); + } + private static void PublishEquipmentDestroyedEvents( AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone, @@ -1801,7 +1829,8 @@ namespace GameCult.Aetheria.State.Verse Math.Max(0, value.EvaluateStat(22)), Math.Max(0, value.EvaluateStat(23)), ReadNumber(value.Payload, 25), PositiveOr(ReadNumber(value.Payload, 26), 1), PositiveOr(ReadNumber(value.Payload, 27), 1), PositiveOr(ReadNumber(value.Payload, 29), 1), - PositiveOr(ReadNumber(value.Payload, 31), 1), PositiveOr(ReadNumber(value.Payload, 32), 1))) + PositiveOr(ReadNumber(value.Payload, 30), 1), PositiveOr(ReadNumber(value.Payload, 31), 1), + PositiveOr(ReadNumber(value.Payload, 32), 1))) .ToArray(); } @@ -1850,6 +1879,7 @@ namespace GameCult.Aetheria.State.Verse PositiveOr(behavior.EvaluateStat(23), settings.WeaponLockAngleDegrees), PositiveOr(behavior.EvaluateStat(24), settings.WeaponLockDirectionImpact), Math.Max(0, behavior.EvaluateStat(25)), + Math.Max(0, behavior.EvaluateStat(11)), Math.Max(0, behavior.EvaluateStat(5)), Math.Max(0, behavior.EvaluateStat(15)), ReadEnumName(behavior.Payload, 1, "Kinetic"), @@ -1870,7 +1900,7 @@ namespace GameCult.Aetheria.State.Verse 1, 0, false, settings.WeaponLockSpeed, settings.WeaponLockSensorImpact, settings.WeaponLockAngleDegrees, settings.WeaponLockDirectionImpact, settings.WeaponLockDecayPerSecond, - 0, 0, "Kinetic", 0, 0); + 0, 0, 0, "Kinetic", 0, 0); } private static AetheriaRuntimeWeaponStateCommit EnsureWeaponState( @@ -2011,7 +2041,7 @@ namespace GameCult.Aetheria.State.Verse int magazineSize, double reloadTime, int burstCount, double burstTime, bool singleAmmoBurst, double lockSpeed, double lockSensorImpact, double lockAngleDegrees, double lockDirectionImpact, double lockDecayPerSecond, - double minRange = 0, double spread = 0, string damageType = "Kinetic", + double visibility = 0, double minRange = 0, double spread = 0, string damageType = "Kinetic", double penetration = 0, double damageSpread = 0) { State = state; ItemKey = itemKey; Damage = damage; Range = range; Cooldown = cooldown; @@ -2020,7 +2050,7 @@ namespace GameCult.Aetheria.State.Verse BurstCount = burstCount; BurstTime = burstTime; SingleAmmoBurst = singleAmmoBurst; LockSensorImpact = lockSensorImpact; LockAngleDegrees = lockAngleDegrees; LockDirectionImpact = lockDirectionImpact; LockDecayPerSecond = lockDecayPerSecond; - MinRange = minRange; Spread = spread; + Visibility = visibility; MinRange = minRange; Spread = spread; DamageType = damageType; Penetration = penetration; DamageSpread = damageSpread; } @@ -2043,6 +2073,7 @@ namespace GameCult.Aetheria.State.Verse public double LockAngleDegrees { get; } public double LockDirectionImpact { get; } public double LockDecayPerSecond { get; } + public double Visibility { get; } public double MinRange { get; } public double Spread { get; } public string DamageType { get; } @@ -2053,19 +2084,19 @@ namespace GameCult.Aetheria.State.Verse private sealed class ResolvedConstantWeapon { public ResolvedConstantWeapon(AetheriaRuntimeWeaponStateCommit state, string itemKey, - bool selected, double damage, double range, double energy, double heat, string ammoItemKey, + bool selected, double damage, double range, double energy, double heat, double visibility, string ammoItemKey, int magazineSize, double reloadTime, double ammoIntervalDuration, double minRange, double spread) { State = state; ItemKey = itemKey; Selected = selected; Damage = damage; Range = range; - Energy = energy; Heat = heat; AmmoItemKey = ammoItemKey; + Energy = energy; Heat = heat; Visibility = visibility; AmmoItemKey = ammoItemKey; MagazineSize = magazineSize; ReloadTime = reloadTime; AmmoIntervalDuration = ammoIntervalDuration; MinRange = minRange; Spread = spread; } public ResolvedWeapon ResolutionShot(double deltaSeconds) => new ResolvedWeapon( State, ItemKey, Damage * deltaSeconds, Range, 0, 1, 0, 0, "", 0, 0, - 1, 0, false, 1, 0, 180, 0, 0, MinRange, Spread, "Kinetic", 0, 0); + 1, 0, false, 1, 0, 180, 0, 0, Visibility, MinRange, Spread, "Kinetic", 0, 0); public AetheriaRuntimeWeaponStateCommit State { get; } public string ItemKey { get; } @@ -2074,6 +2105,7 @@ namespace GameCult.Aetheria.State.Verse public double Range { get; } public double Energy { get; } public double Heat { get; } + public double Visibility { get; } public string AmmoItemKey { get; } public int MagazineSize { get; } public double ReloadTime { get; } @@ -2108,12 +2140,13 @@ namespace GameCult.Aetheria.State.Verse public ResolvedChargedWeapon(ResolvedWeapon baseWeapon, AetheriaRuntimeLoadoutItemCommit item, bool requested, double chargeTime, double chargeEnergy, double chargeHeat, double failureCharge, double failureDamage, double damageMultiplier, - double burstMultiplier, double velocityMultiplier, double heatMultiplier) + double burstMultiplier, double visibilityMultiplier, double velocityMultiplier, double heatMultiplier) { Base = baseWeapon; Item = item; Requested = requested; ChargeTime = chargeTime; ChargeEnergy = chargeEnergy; ChargeHeat = chargeHeat; FailureCharge = failureCharge; FailureDamage = failureDamage; DamageMultiplier = damageMultiplier; - BurstMultiplier = burstMultiplier; VelocityMultiplier = velocityMultiplier; + BurstMultiplier = burstMultiplier; VisibilityMultiplier = visibilityMultiplier; + VelocityMultiplier = velocityMultiplier; HeatMultiplier = heatMultiplier; } public ResolvedWeapon CommittedShot() @@ -2125,6 +2158,7 @@ namespace GameCult.Aetheria.State.Verse Base.Energy * Base.BurstCount / count, Base.AmmoItemKey, Base.MagazineSize, Base.ReloadTime, count, Base.BurstTime, Base.SingleAmmoBurst, Base.LockSpeed, Base.LockSensorImpact, Base.LockAngleDegrees, Base.LockDirectionImpact, Base.LockDecayPerSecond, + Base.Visibility * VisibilityMultiplier, Base.MinRange, Base.Spread, Base.DamageType, Base.Penetration, Base.DamageSpread); } public ResolvedWeapon Base { get; } @@ -2137,42 +2171,11 @@ namespace GameCult.Aetheria.State.Verse public double FailureDamage { get; } public double DamageMultiplier { get; } public double BurstMultiplier { get; } + public double VisibilityMultiplier { get; } public double VelocityMultiplier { get; } public double HeatMultiplier { get; } } - private static void RefreshContacts( - IReadOnlyList entities, - AetheriaRuntimeDaemonSimulationSettings settings, - AetheriaRuntimeCatalogSnapshot? catalog) - { - foreach (var observer in entities) - { - var sensorRange = ResolveSensorRange(observer, settings, catalog); - var contacts = new List(); - foreach (var target in entities) - { - if (target.EntityIndex == observer.EntityIndex) - continue; - - var distance = Math.Sqrt(DistanceSq(observer, target)); - var visible = distance <= sensorRange; - if (!visible && !Hostile(observer, target)) - continue; - - contacts.Add(new AetheriaRuntimeEntityContactCommit - { - TargetEntityIndex = target.EntityIndex, - InfoGathered = visible ? 1.0 : 0.25, - Hostile = Hostile(observer, target), - Visible = visible - }); - } - - observer.Contacts = contacts; - } - } - private static void EnsureStats( IReadOnlyList entities, AetheriaRuntimeDaemonSimulationSettings settings, @@ -2509,30 +2512,6 @@ namespace GameCult.Aetheria.State.Verse string.Equals(right.FactionKey, "raider", StringComparison.OrdinalIgnoreCase)); } - private static double ResolveSensorRange( - AetheriaRuntimeEntitySnapshotCommit entity, - AetheriaRuntimeDaemonSimulationSettings settings, - AetheriaRuntimeCatalogSnapshot? catalog) - { - if (!entity.IsActive) - return 0; - - var equipmentStates = (entity.EquipmentStates ?? Array.Empty()) - .Where(state => state != null) - .ToDictionary(state => state.EquipmentIndex); - var sensitivitySquared = AetheriaRuntimeEquippedBehaviorQueries.FindOperational(entity, catalog, "Sensor") - .Select(sensor => Math.Max(0, sensor.EvaluateStat( - 3, - equipmentStates.TryGetValue(sensor.EquipmentIndex, out var state) - ? state.ThermalPerformance - : 1))) - .Select(sensitivity => sensitivity * sensitivity) - .Sum(); - return sensitivitySquared > 0 - ? settings.EntitySensorRange * Math.Sqrt(sensitivitySquared) - : settings.EntitySensorRange; - } - private static double DefaultHull( AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeDaemonSimulationSettings settings) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs index 11c04c2bb..1098e7023 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs @@ -108,7 +108,9 @@ namespace GameCult.Aetheria.State.Verse double wormholeExitCurveStart = AetheriaDefaultRaw.WormholeExitCurveStart, double wormholeExitVelocity = AetheriaDefaultRaw.WormholeExitVelocity, double wormholeExitRadius = AetheriaDefaultRaw.WormholeExitRadius, - double wormholeDepth = AetheriaDefaultRaw.WormholeDepth) + double wormholeDepth = AetheriaDefaultRaw.WormholeDepth, + double visibilityDecay = AetheriaDefaultRaw.VisibilityDecay, + double targetInfoDecay = AetheriaDefaultRaw.TargetInfoDecay) { PawnSpeed = PositiveOr(pawnSpeed, AetheriaDefaultRaw.PawnSpeed); RaiderSpeed = PositiveOr(raiderSpeed, AetheriaDefaultRaw.RaiderSpeed); @@ -161,6 +163,8 @@ namespace GameCult.Aetheria.State.Verse WormholeExitVelocity = PositiveOr(wormholeExitVelocity, AetheriaDefaultRaw.WormholeExitVelocity); WormholeExitRadius = PositiveOr(wormholeExitRadius, AetheriaDefaultRaw.WormholeExitRadius); WormholeDepth = PositiveOr(wormholeDepth, AetheriaDefaultRaw.WormholeDepth); + VisibilityDecay = NonNegativeOr(visibilityDecay, AetheriaDefaultRaw.VisibilityDecay); + TargetInfoDecay = NonNegativeOr(targetInfoDecay, AetheriaDefaultRaw.TargetInfoDecay); } public double PawnSpeed { get; } @@ -216,6 +220,8 @@ namespace GameCult.Aetheria.State.Verse public double WormholeExitVelocity { get; } public double WormholeExitRadius { get; } public double WormholeDepth { get; } + public double VisibilityDecay { get; } + public double TargetInfoDecay { get; } private static double PositiveOr(double value, double fallback) { @@ -285,6 +291,8 @@ namespace GameCult.Aetheria.State.Verse public const double WormholeExitVelocity = 20.0; public const double WormholeExitRadius = 50.0; public const double WormholeDepth = 1000.0; + public const double VisibilityDecay = 0.5; + public const double TargetInfoDecay = 0.5; } } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs index 9462371b5..3a25ab4c1 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs @@ -339,10 +339,7 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonIntentState commands, AetheriaRuntimeDaemonIntentState agents) { - var merged = new AetheriaRuntimeDaemonIntentState - { - SensorPingRequested = commands.SensorPingRequested || agents.SensorPingRequested - }; + var merged = new AetheriaRuntimeDaemonIntentState(); merged.Movements.AddRange(commands.Movements); merged.Movements.AddRange(agents.Movements); merged.WeaponGroups.AddRange(commands.WeaponGroups); @@ -355,6 +352,8 @@ namespace GameCult.Aetheria.State.Verse merged.Docking.AddRange(agents.Docking); merged.Wormholes.AddRange(commands.Wormholes); merged.Wormholes.AddRange(agents.Wormholes); + merged.SensorPings.AddRange(commands.SensorPings); + merged.SensorPings.AddRange(agents.SensorPings); return merged; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSensorSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSensorSimulation.cs new file mode 100644 index 000000000..e818a1138 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSensorSimulation.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeSensorSimulation + { + public static void StepZone( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + IReadOnlyList? pingIntents, + AetheriaRuntimeCatalogSnapshot? catalog, + double deltaSeconds, + double targetInfoDecay, + double detectionThreshold, + long frameId) + { + if (catalog == null || deltaSeconds <= 0) + return; + + foreach (var observer in entities) + { + if (observer == null || !observer.IsActive) + continue; + + var contacts = (observer.Contacts ?? Array.Empty()) + .Where(contact => contact != null) + .ToDictionary(contact => contact.TargetEntityIndex); + var previouslyVisible = contacts.Values + .Where(contact => contact.Visible) + .Select(contact => contact.TargetEntityIndex) + .ToHashSet(); + var sensors = AetheriaRuntimeEquippedBehaviorQueries.FindOperational( + observer, catalog, AetheriaRuntimeBehaviorKinds.Sensor); + var pingRequested = (pingIntents ?? Array.Empty()) + .Any(intent => intent != null && ActorMatches( + intent.ActorEntityKey, zone.ZoneIndex, observer.EntityIndex)); + if (pingRequested && sensors.Count > 0) + TryStartPing(run, zone, observer, sensors[sensors.Count - 1], catalog, frameId); + + foreach (var sensor in sensors) + StepSensor(run, zone, observer, sensor, entities, contacts, catalog, + deltaSeconds, targetInfoDecay, frameId); + + observer.Contacts = entities + .Where(target => target != null && target.EntityIndex != observer.EntityIndex) + .Select(target => + { + contacts.TryGetValue(target.EntityIndex, out var current); + var information = Clamp01(current?.InfoGathered ?? 0); + return new AetheriaRuntimeEntityContactCommit + { + TargetEntityIndex = target.EntityIndex, + InfoGathered = information, + Hostile = Hostile(observer, target), + Visible = information > detectionThreshold + }; + }) + .OrderBy(contact => contact.TargetEntityIndex) + .ToArray(); + if (observer.TargetEntityIndex >= 0 && + previouslyVisible.Contains(observer.TargetEntityIndex) && + !observer.Contacts.Any(contact => + contact.TargetEntityIndex == observer.TargetEntityIndex && contact.Visible)) + observer.TargetEntityIndex = -1; + } + } + + private static void TryStartPing( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit observer, + AetheriaRuntimeEquippedBehavior sensor, + AetheriaRuntimeCatalogSnapshot catalog, + long frameId) + { + if (sensor.State.PingCooldown >= 0) + return; + var thermal = ThermalPerformance(observer, sensor.EquipmentIndex); + if (!AetheriaRuntimeEnergySimulation.TryConsume( + observer, catalog, Math.Max(0, sensor.EvaluateStat(6, thermal)))) + return; + + sensor.State.Pinging = true; + sensor.State.PingCooldown = 1; + sensor.State.PingLerp = 0; + sensor.State.PingRadius = 0; + sensor.State.PingedEntityIndices = Array.Empty(); + sensor.State.PingedEntityCount = 0; + AetheriaRuntimeVisibilitySimulation.SetTransientSource( + observer, + $"sensor:{sensor.EquipmentIndex}:{sensor.BehaviorIndex}", + sensor.EvaluateStat(7, thermal)); + AppendPingEvent(run, zone, observer, sensor, frameId, "sensor.ping.started"); + } + + private static void StepSensor( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit observer, + AetheriaRuntimeEquippedBehavior sensor, + IReadOnlyList entities, + IDictionary contacts, + AetheriaRuntimeCatalogSnapshot catalog, + double deltaSeconds, + double targetInfoDecay, + long frameId) + { + var thermal = ThermalPerformance(observer, sensor.EquipmentIndex); + if (sensor.State.Pinging) + { + var duration = Math.Max(0.000001, ReadNumber(sensor, 10, 2)); + var exponent = ReadNumber(sensor, 11, 0.5); + sensor.State.PingLerp += deltaSeconds / duration; + sensor.State.PingRadius = Math.Max(0, sensor.EvaluateStat(8, thermal)) * + Math.Pow(Math.Max(0, sensor.State.PingLerp), exponent); + if (sensor.State.PingLerp > 1) + { + sensor.State.Pinging = false; + AppendPingEvent(run, zone, observer, sensor, frameId, "sensor.ping.ended"); + } + } + + var cooldownDuration = Math.Max(0.000001, sensor.EvaluateStat(9, thermal)); + sensor.State.PingCooldown -= deltaSeconds / cooldownDuration; + var pinged = new HashSet(sensor.State.PingedEntityIndices ?? Array.Empty()); + var forward = SensorDirection(observer, sensor); + var sensitivity = Math.Max(0, sensor.EvaluateStat(3, thermal)); + var pingBoost = Math.Max(0, sensor.EvaluateStat(5, thermal)); + var curve = ReadCurveKeys(sensor, 4); + foreach (var target in entities) + { + if (target == null || target.EntityIndex == observer.EntityIndex) + continue; + + var dx = target.PositionX - observer.PositionX; + var dz = target.PositionZ - observer.PositionZ; + var distance = Math.Sqrt(dx * dx + dz * dz); + var previous = contacts.TryGetValue(target.EntityIndex, out var contact) + ? contact.InfoGathered + : 0; + double next; + if (!pinged.Contains(target.EntityIndex) && distance < sensor.State.PingRadius) + { + pinged.Add(target.EntityIndex); + next = Clamp01(previous + target.Visibility * sensitivity * pingBoost * distance); + } + else + { + var direction = Normalize(dx, dz, forward.X, forward.Y); + var angle = Math.Acos(Clamp(forward.X * direction.X + forward.Y * direction.Y, -1, 1)); + var angularSensitivity = curve.Count == 0 + ? 0 + : AetheriaRuntimeDaemonItemStatQueries.SampleCurve(curve, angle / Math.PI); + next = Clamp01(previous + target.Visibility * sensitivity * angularSensitivity * + deltaSeconds / Math.Max(0.000001, distance)); + } + next *= 1 - Math.Max(0, targetInfoDecay) * deltaSeconds; + contacts[target.EntityIndex] = new AetheriaRuntimeEntityContactCommit + { + TargetEntityIndex = target.EntityIndex, + InfoGathered = next, + Hostile = Hostile(observer, target) + }; + } + sensor.State.PingedEntityIndices = pinged.OrderBy(index => index).ToArray(); + sensor.State.PingedEntityCount = pinged.Count; + } + + private static IReadOnlyList ReadCurveKeys( + AetheriaRuntimeEquippedBehavior behavior, + int key) + { + var value = (behavior.Payload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == key)?.Value; + return ExtractCurveKeys(value).ToArray(); + } + + private static IEnumerable ExtractCurveKeys( + AetheriaRuntimeBehaviorValue? value) + { + var children = value?.Children ?? Array.Empty(); + if (children.Count >= 4 && children.Take(4).All(child => + child != null && string.Equals(child.Kind, "number", StringComparison.OrdinalIgnoreCase))) + { + yield return new AetheriaRuntimeCurveKey( + children[0].NumberValue, + children[1].NumberValue, + children[2].NumberValue, + children[3].NumberValue); + yield break; + } + foreach (var child in children) + foreach (var key in ExtractCurveKeys(child)) + yield return key; + } + + private static (double X, double Y) SensorDirection( + AetheriaRuntimeEntitySnapshotCommit observer, + AetheriaRuntimeEquippedBehavior sensor) + { + var direction = Normalize(observer.DirectionX, observer.DirectionY, 0, 1); + return AetheriaRuntimeEquipmentRotation.RotateQuarter( + direction.X, + direction.Y, + AetheriaRuntimeEquipmentRotation.QuarterTurns(sensor.Slot.Rotation)); + } + + private static double ReadNumber( + AetheriaRuntimeEquippedBehavior behavior, + int key, + double fallback) + { + var value = (behavior.Payload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == key)?.Value; + return value == null || !double.IsFinite(value.NumberValue) ? fallback : value.NumberValue; + } + + private static void AppendPingEvent( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit observer, + AetheriaRuntimeEquippedBehavior sensor, + long frameId, + string kind) + { + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"{kind}:{zone.ZoneIndex}:{observer.EntityIndex}:{sensor.EquipmentIndex}:{frameId}", + Kind = kind, + FrameId = frameId, + ZoneIndex = zone.ZoneIndex, + SourceEntityIndex = observer.EntityIndex, + SubjectKey = sensor.Item.ItemKey + }); + } + + private static bool ActorMatches(string actorEntityKey, int zoneIndex, int entityIndex) => + AetheriaRuntimeRunCheckpointCommit.TryParseEntityKey( + actorEntityKey, out var actorZoneIndex, out var actorEntityIndex) && + actorZoneIndex == zoneIndex && actorEntityIndex == entityIndex; + + private static double ThermalPerformance(AetheriaRuntimeEntitySnapshotCommit entity, int equipmentIndex) => + (entity.EquipmentStates ?? Array.Empty()) + .FirstOrDefault(state => state != null && state.EquipmentIndex == equipmentIndex)? + .ThermalPerformance ?? 1; + + private static bool Hostile( + AetheriaRuntimeEntitySnapshotCommit left, + AetheriaRuntimeEntitySnapshotCommit right) => + string.Equals(left.FactionKey, "player", StringComparison.OrdinalIgnoreCase) != + string.Equals(right.FactionKey, "player", StringComparison.OrdinalIgnoreCase) && + (string.Equals(left.FactionKey, "raider", StringComparison.OrdinalIgnoreCase) || + string.Equals(right.FactionKey, "raider", StringComparison.OrdinalIgnoreCase)); + + private static (double X, double Y) Normalize( + double x, double y, double fallbackX, double fallbackY) + { + var length = Math.Sqrt(x * x + y * y); + return length <= 0.000001 ? (fallbackX, fallbackY) : (x / length, y / length); + } + + private static double Clamp01(double value) => Clamp(value, 0, 1); + private static double Clamp(double value, double minimum, double maximum) => + value < minimum ? minimum : value > maximum ? maximum : value; + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSensorSimulation.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSensorSimulation.cs.meta new file mode 100644 index 000000000..33cacf992 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSensorSimulation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d92fabacd804a47b1823a42aad8a73b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index 679e3cdc3..37151636f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -1511,5 +1511,8 @@ namespace GameCult.Aetheria.State.Verse [Key(56)] public string MiningToolAsteroidBeltKey { get; set; } = ""; + + [Key(57)] + public IReadOnlyList PingedEntityIndices { get; set; } = Array.Empty(); } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVisibilitySimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVisibilitySimulation.cs index d3adf80aa..ef49ffebe 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVisibilitySimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVisibilitySimulation.cs @@ -8,9 +8,51 @@ namespace GameCult.Aetheria.State.Verse { public static class AetheriaRuntimeVisibilitySimulation { + private const string TransientVisibilityPrefix = "transient-visibility:"; private const string ReflectorVisibilityGrid = "reflector-visibility"; private const string EquipmentVisibilityGrid = "equipment-visibility"; + public static void BeginTick( + IReadOnlyList? entities, + double deltaSeconds, + double visibilityDecay) + { + if (deltaSeconds <= 0) + return; + foreach (var entity in entities ?? Array.Empty()) + { + if (entity == null) + continue; + var grids = (entity.StatGrids ?? Array.Empty()).ToList(); + foreach (var grid in grids.Where(candidate => + candidate != null && + (candidate.Name ?? "").StartsWith(TransientVisibilityPrefix, StringComparison.Ordinal)).ToArray()) + { + var previous = grid.Values.FirstOrDefault(); + var next = previous * Math.Exp(-Math.Max(0, visibilityDecay) * deltaSeconds); + if (next < 0.1) + { + next = 0; + grids.Remove(grid); + } + else + { + grid.Values = [next]; + } + entity.Visibility = Math.Max(0, entity.Visibility - previous) + next; + } + entity.StatGrids = grids; + } + } + + public static void SetTransientSource( + AetheriaRuntimeEntitySnapshotCommit entity, + string sourceKey, + double value) + { + SetVisibilitySource(entity, TransientVisibilityPrefix + (sourceKey ?? ""), Math.Max(0, value)); + } + public static void StepZone( AetheriaRuntimeZoneSnapshotCommit? zone, IReadOnlyList? entities, diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 86b2e6f97..1599a6f6b 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -292,18 +292,24 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. equipped-item and active-consumable behavior instances through narrow behavior-owned restore methods; construction/loadout blueprints do not own behavior replay. -- Passive contact reach is daemon-owned and derived from enabled installed - `Sensor` behavior sensitivity. Arrays combine by root-sum-square, so larger - hulls can buy reach by devoting real equipment volume, mass, power, cooling, - and computation to sensing. Entity kind is not an input. Cargo is not - installed equipment. Hull-authored `Sensors` hardpoints own the physical - installation envelope: a smaller same-type array may occupy a larger mount, - while generated loadouts choose the largest compatible footprint before - applying faction, distance, and price weighting. Station-scale reach emerges - from station-scale hardpoints and gear rather than a station multiplier or - sensor-count branch. 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. +- Sensor contact information is daemon-owned. Each operational installed + `Sensor` behavior executes the fossil information equation against target + visibility, distance, observer-relative angle, its authored sensitivity + curve, and fixed simulation time, then applies the authored information + decay. Contacts become visible only when accumulated information crosses the + provider-owned detection threshold. Entity kind is not an input. Cargo is + not installed equipment. Hull-authored `Sensors` hardpoints own only the + installation envelope; generated loadouts still choose compatible equipment + through the ordinary footprint, faction, distance, and price rules. +- Active ping is the same behavior's actor-scoped daemon command path. The last + operational installed sensor owns the request, energy gate, cooldown, + expanding radius, and persistent set of already-crossed target indices. + A target receives the fossil ping boost and named transient signature once + per expansion crossing, including after reconnect; clients cannot replay the + boost by resending or rehydrating a request. Manual and future autonomous + pings must enter this same typed intent. The removed actorless ping flag and + range-based perfect-information refresh are forbidden writers and no longer + decide contacts. - 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, @@ -1069,17 +1075,22 @@ projectile lead, hardpoint gating, progressive lock, and target destruction. thrust/turn feedback still require proof. -### Authoritative Reflected Visibility Contract +### Authoritative Visibility Contract -- Owner: the Aetheria daemon owns each entity's equipment and reflected stellar - signature. It evaluates operational `Visibility` and `Reflector` behaviors - after energy and thermal state settle and before contact refresh. Eve and - Unity only present the resulting visibility and named sources. -- Inputs: entity position, equipped reflector performance and thermal state, - daemon-owned sun mass, sun light-radius multiplier, the provider render - settings light-radius curve, and resolved orbit positions. -- Outputs: aggregate entity visibility plus inspectable, reconnectable - `equipment-visibility` and `reflector-visibility` source grids. +- Owner: the Aetheria daemon owns each entity's emitted, weapon, ping, and + reflected stellar signature. It decays named transient sources, evaluates + operational `Visibility` and `Reflector` behaviors after energy and thermal + state settle, applies authored weapon visibility when a shot commits, and + executes sensor pings before contact projection. Eve and Unity only present + the resulting visibility and named sources. +- Inputs: fixed simulation time, authored visibility-decay setting, entity + position, equipped visibility and reflector performance, thermal state, + committed weapon visibility (including the charged-weapon multiplier), ping + visibility, daemon-owned sun mass, sun light-radius multiplier, the provider + render settings light-radius curve, and resolved orbit positions. +- Outputs: aggregate entity visibility plus inspectable, reconnectable constant + equipment, reflector, weapon, and ping source grids. Transient source values + follow the fossil exponential decay and disappear below its cutoff. - Shared-field invariant: daemon visibility and environment rendering resolve body positions through `AetheriaRuntimeOrbitQueries`; gravity-center overrides and nested authored orbits cannot silently put the visible sun and its @@ -1090,11 +1101,13 @@ projectile lead, hardpoint gating, progressive lock, and target destruction. parameters are levers; they do not decide whether another entity detects the reflector. - Forbidden writers: contact refresh cannot overwrite emitted signature, and - clients cannot submit visibility, stellar light, or reflector results. + clients cannot submit visibility, transient decay, stellar light, ping + crossings, weapon flashes, or reflector results. - Verification: daemon smoke proves evaluated constant equipment visibility, a displaced authored sun orbit, the fossil pulse equation, evaluated reflector - cross-section, named source publication, and a second tick with no visibility - accumulation. + cross-section, named source publication without accumulation, actor-scoped + energy-gated ping with reconnect-stable crossed-target state, committed weapon + visibility, and exact exponential transient decay. - Open parity: active consumable reflector effects and complete visibility- source cardinality remain to be migrated. diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index a468a4539..71e1399e2 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -44,7 +44,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; the released full-session profile starts with no pickup, destroys a deterministic target, observes its canonical cargo drop, tractors it into a Ymir Begin contact, and proves exactly one `ymir-fact:` collection receipt plus cargo `0` to `1` | 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, `Visibility`, `Reflector`, decaying signature sources, stellar light, and observer information thresholds | daemon | contacts, named visibility sources, and ping events | indicators/maps and provider environment lowering | passive reach derives from enabled installed Sensor behaviors with diminishing-return array composition regardless of entity kind; station-scale arrays are ordinary gear installed into larger hull-authored sensor hardpoints, with smaller same-type arrays remaining valid fallbacks; generation prefers the largest fitting footprint and contains no station/ship sensor-count branch. Operational `Visibility` equipment publishes evaluated constant signature; reflectors multiply evaluated cross-section by the fossil exponent-8 stellar `PowerPulse` after thermal settlement. Both named sources are reconnectable and do not accumulate. Gameplay light and environment projection share resolved authored orbit/body centers and provider-owned sun radius inputs. 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 | active consumable visibility/reflector effects, source cardinality, transient decay, ping, classification timelines, and canonical catalog station-array data | +| Sensors/visibility | installed sensor sensitivity and angular curve, `Visibility`, `Reflector`, decaying signature sources, stellar light, ping state, and observer information thresholds | daemon | accumulated contacts, named visibility sources, and ping events | indicators/maps and provider environment lowering | every operational installed Sensor executes the fossil visibility/sensitivity/angle/distance information equation and decay; visibility is thresholded by the provider setting. Actor-scoped ping owns energy, cooldown, expansion radius, reconnect-stable crossed-target state, one-time information boost, and transient signature. Operational `Visibility` equipment publishes evaluated constant signature; committed weapons publish authored signature including charged-shot multiplication; transient weapon and ping sources decay by the fossil exponential rule. Reflectors multiply evaluated cross-section by the exponent-8 stellar `PowerPulse` after thermal settlement. Gameplay light and environment projection share resolved authored orbit/body centers and provider-owned sun radius inputs. Contact refresh no longer invents perfect information or 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 | active consumable visibility/reflector effects, complete source cardinality, classification timelines, and canonical catalog station-array data | | 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, loadout-driven attack, haul, mining/offload, resource surveying, physical station towing, persistent orbit patrol, route-aware assignment, and autonomous return home are wired through shared commands. Combat samples the fossil DPS/range policy, selects live weapon groups, leads projectiles, strafes relative to target attitude, and gates fire on catalog placement rotation. Cross-zone task and home routes now survive the full daemon-owned wormhole timeline instead of instant transfer; attack admission rejects missing/friendly targets and unavailable weapon groups before durable queueing | cancellation, reconnect, multi-client command transfer, and model-local barrel-direction metadata | | 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`, `ItemUsage`, and stateful `Cooldown` payloads after energy tick initialization; every effect and payload state has stable authored identity; cooldowns update before the ordered chain, require state strictly below zero, reset to one on success, and continue aging behind earlier failures; false and unsupported payloads stop later execution with a typed fact; timers retain the fossil's exact-zero final tick; Unity reconciles presentation by effect identity and cannot restore active behavior state by list index | catalog revision pinning, stat modifiers, remaining behavior families, and reconnect presentation proof |