Run sensor visibility mechanics in daemon

This commit is contained in:
Meta Vens 2026-07-17 02:28:38 +01:00
commit 0724ee3cf8
14 changed files with 561 additions and 105 deletions

View file

@ -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<AetheriaRuntimeShapeCell>(),
@ -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
{

View file

@ -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]

View file

@ -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<string> WeaponKinds = new HashSet<string>(StringComparer.Ordinal)
{

View file

@ -19,11 +19,12 @@ namespace GameCult.Aetheria.State.Verse
new List<AetheriaRuntimeDaemonDockingIntent>();
public List<AetheriaRuntimeDaemonWormholeIntent> Wormholes { get; } =
new List<AetheriaRuntimeDaemonWormholeIntent>();
public bool SensorPingRequested { get; set; }
public List<AetheriaRuntimeDaemonSensorPingIntent> SensorPings { get; } =
new List<AetheriaRuntimeDaemonSensorPingIntent>();
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; } = "";

View file

@ -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;
}

View file

@ -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<AetheriaRuntimeEntityContactCommit>())
.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<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonSimulationSettings settings,
AetheriaRuntimeCatalogSnapshot? catalog)
{
foreach (var observer in entities)
{
var sensorRange = ResolveSensorRange(observer, settings, catalog);
var contacts = new List<AetheriaRuntimeEntityContactCommit>();
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<AetheriaRuntimeEntitySnapshotCommit> 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<AetheriaRuntimeEquipmentStateCommit>())
.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)

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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<AetheriaRuntimeEntitySnapshotCommit> entities,
IReadOnlyList<AetheriaRuntimeDaemonSensorPingIntent>? 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<AetheriaRuntimeEntityContactCommit>())
.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<AetheriaRuntimeDaemonSensorPingIntent>())
.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<int>();
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<AetheriaRuntimeEntitySnapshotCommit> entities,
IDictionary<int, AetheriaRuntimeEntityContactCommit> 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<int>(sensor.State.PingedEntityIndices ?? Array.Empty<int>());
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<AetheriaRuntimeCurveKey> ReadCurveKeys(
AetheriaRuntimeEquippedBehavior behavior,
int key)
{
var value = (behavior.Payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value;
return ExtractCurveKeys(value).ToArray();
}
private static IEnumerable<AetheriaRuntimeCurveKey> ExtractCurveKeys(
AetheriaRuntimeBehaviorValue? value)
{
var children = value?.Children ?? Array.Empty<AetheriaRuntimeBehaviorValue>();
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<AetheriaRuntimeBehaviorField>())
.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<AetheriaRuntimeEquipmentStateCommit>())
.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;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d92fabacd804a47b1823a42aad8a73b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1511,5 +1511,8 @@ namespace GameCult.Aetheria.State.Verse
[Key(56)]
public string MiningToolAsteroidBeltKey { get; set; } = "";
[Key(57)]
public IReadOnlyList<int> PingedEntityIndices { get; set; } = Array.Empty<int>();
}
}

View file

@ -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<AetheriaRuntimeEntitySnapshotCommit>? entities,
double deltaSeconds,
double visibilityDecay)
{
if (deltaSeconds <= 0)
return;
foreach (var entity in entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
{
if (entity == null)
continue;
var grids = (entity.StatGrids ?? Array.Empty<AetheriaRuntimeEntityStatGridCommit>()).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<AetheriaRuntimeEntitySnapshotCommit>? entities,

View file

@ -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.

View file

@ -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 |