Aetheria/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs
2026-07-12 06:01:14 +01:00

2188 lines
117 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public static class AetheriaRuntimeDaemonSimulation
{
private const string Hull = "hull";
private const string Shield = "shield";
private const string Heat = "heat";
private const string Armor = "armor";
private const string MaximumArmor = "maximumArmor";
public static void Step(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeDaemonIntentState intents,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings,
IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics,
IAetheriaRuntimeWorldPhysics worldPhysics,
AetheriaRuntimeCatalogSnapshot? catalog = null,
long frameId = 0,
double simulationTimeSeconds = 0)
{
if (run == null || deltaSeconds <= 0)
return;
if (physicalPayloadPhysics == null)
throw new ArgumentNullException(nameof(physicalPayloadPhysics));
if (worldPhysics == null)
throw new ArgumentNullException(nameof(worldPhysics));
foreach (var zone in run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
{
if (zone == null)
continue;
var entities = (zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.Where(entity => entity != null)
.ToArray();
if (entities.Length == 0)
continue;
StepPickupLifetimes(run, zone, frameId, deltaSeconds);
EnsureStats(entities, settings, catalog);
foreach (var entity in entities)
{
AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog);
AetheriaRuntimeThermalSimulation.EnsureState(entity);
AetheriaRuntimeThermalSimulation.UpdateEquipmentStates(entity, catalog, deltaSeconds);
AetheriaRuntimeEnergySimulation.BeginTick(entity, catalog);
}
foreach (var movement in intents?.Movements ?? Enumerable.Empty<AetheriaRuntimeDaemonMovementIntent>())
ApplyMovementIntent(run, entities, movement, settings);
StepTractorPower(entities, deltaSeconds);
StepRaiderAi(entities);
StepTargetPursuit(entities, settings);
var worldStep = StepWorldPhysics(zone, entities, deltaSeconds, worldPhysics);
ResolvePickupContacts(run, zone, entities, worldStep, catalog, frameId);
StepCombat(run, zone, entities, intents, deltaSeconds, settings, physicalPayloadPhysics, catalog,
frameId, simulationTimeSeconds);
AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds);
AetheriaRuntimeSurveySimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds);
RefreshContacts(entities, settings);
}
}
private static void StepPickupLifetimes(AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone, long frameId, double deltaSeconds)
{
foreach (var pickup in zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
if (pickup != null) pickup.AgeSeconds += deltaSeconds;
foreach (var pickup in (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.Where(pickup => pickup != null && pickup.AgeSeconds >= pickup.LifetimeSeconds))
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:pickup:{pickup.PickupIndex}:expired", Kind = "pickup.expired", FrameId = frameId, ZoneIndex = zone.ZoneIndex, PickupIndex = pickup.PickupIndex, ItemKey = pickup.Item?.ItemKey ?? "" });
zone.DroppedPickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.Where(pickup => pickup != null && pickup.AgeSeconds < pickup.LifetimeSeconds)
.ToArray();
}
private static void StepTractorPower(IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities, double deltaSeconds)
{
foreach (var entity in entities)
{
var delta = entity.TractorTargetPower - entity.TractorPower;
entity.TractorPower += Math.Sign(delta) * Math.Min(Math.Abs(delta), deltaSeconds * 2.0);
entity.TractorPower = Clamp01(entity.TractorPower);
}
}
private static void ApplyMovementIntent(
AetheriaRuntimeRunCheckpointCommit run,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonMovementIntent? movement,
AetheriaRuntimeDaemonSimulationSettings settings)
{
if (movement == null || !TryParseEntityIndex(movement.ActorEntityKey, out var entityIndex))
return;
var entity = entities.FirstOrDefault(candidate => candidate.EntityIndex == entityIndex);
if (entity == null || !IsAlive(entity))
return;
var magnitude = Clamp01(movement.Magnitude);
var normalized = Normalize(movement.DirectionX, movement.DirectionY);
var speed = ResolveSpeed(entity, settings);
entity.VelocityX = normalized.X * speed * magnitude;
entity.VelocityY = normalized.Y * speed * magnitude;
if (magnitude <= 0.001)
entity.TargetEntityIndex = -1;
else
Face(entity, normalized.X, normalized.Y);
}
private static void StepRaiderAi(IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities)
{
foreach (var raider in entities.Where(entity => string.Equals(entity.FactionKey, "raider", StringComparison.OrdinalIgnoreCase)))
{
if (!IsAlive(raider))
continue;
var target = entities
.Where(IsPlayerOwned)
.Where(IsAlive)
.OrderBy(candidate => DistanceSq(raider, candidate))
.FirstOrDefault();
if (target == null)
continue;
raider.TargetEntityIndex = target.EntityIndex;
}
}
private static void StepTargetPursuit(
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var byIndex = entities.ToDictionary(entity => entity.EntityIndex);
foreach (var entity in entities)
{
if (!IsAlive(entity) ||
entity.TargetEntityIndex < 0 ||
!byIndex.TryGetValue(entity.TargetEntityIndex, out var target) ||
!IsAlive(target))
{
continue;
}
var dx = target.PositionX - entity.PositionX;
var dy = target.PositionZ - entity.PositionZ;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance <= settings.AttackRange * settings.AttackHoldRatio)
{
entity.VelocityX *= 0.72;
entity.VelocityY *= 0.72;
Face(entity, dx, dy);
continue;
}
if (string.Equals(entity.FactionKey, "raider", StringComparison.OrdinalIgnoreCase))
{
var direction = Normalize(dx, dy);
var speed = ResolveSpeed(entity, settings);
entity.VelocityX = direction.X * speed;
entity.VelocityY = direction.Y * speed;
Face(entity, direction.X, direction.Y);
}
}
}
private static AetheriaRuntimeWorldStep StepWorldPhysics(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds,
IAetheriaRuntimeWorldPhysics worldPhysics)
{
var result = worldPhysics.Step(zone, entities, deltaSeconds);
var byIndex = entities.ToDictionary(entity => entity.EntityIndex);
foreach (var body in result.Bodies)
{
if (!byIndex.TryGetValue(body.EntityIndex, out var entity)) continue;
entity.PositionX = body.PositionX; entity.PositionZ = body.PositionZ;
entity.VelocityX = body.VelocityX; entity.VelocityY = body.VelocityY;
entity.DirectionX = body.DirectionX; entity.DirectionY = body.DirectionY;
}
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.Where(pickup => pickup != null).ToDictionary(pickup => pickup.PickupIndex);
foreach (var body in result.Pickups)
{
if (!pickups.TryGetValue(body.PickupIndex, out var pickup)) continue;
pickup.PositionX = body.PositionX; pickup.PositionZ = body.PositionZ;
pickup.VelocityX = body.VelocityX; pickup.VelocityZ = body.VelocityZ;
}
foreach (var parent in entities)
foreach (var childIndex in parent.ChildEntityIndices ?? Array.Empty<int>())
{
var child = entities.FirstOrDefault(value => value.EntityIndex == childIndex);
if (child == null) continue;
child.PositionX = parent.PositionX; child.PositionZ = parent.PositionZ;
child.VelocityX = parent.VelocityX; child.VelocityY = parent.VelocityY;
}
return result;
}
private static void ResolvePickupContacts(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeWorldStep worldStep,
AetheriaRuntimeCatalogSnapshot? catalog,
long frameId)
{
foreach (var contact in worldStep.Contacts.Where(contact => contact.PickupIndex >= 0))
{
var entityIndex = Math.Max(contact.EntityAIndex, contact.EntityBIndex);
var entity = entities.FirstOrDefault(value => value.EntityIndex == entityIndex && value.IsActive);
var pickup = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.FirstOrDefault(value => value != null && value.PickupIndex == contact.PickupIndex);
if (entity == null || pickup == null) continue;
if (AetheriaRuntimePickupTransactions.TryCollect(zone, entity, pickup.PickupIndex, catalog, requireRange: false))
{
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:pickup:{pickup.PickupIndex}:collected", Kind = "pickup.collected", FrameId = frameId, ZoneIndex = zone.ZoneIndex, TargetEntityIndex = entity.EntityIndex, PickupIndex = pickup.PickupIndex, ItemKey = pickup.Item?.ItemKey ?? "", ScalarValue = Math.Max(1, pickup.Item?.Quantity ?? 1) });
continue;
}
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:pickup:{pickup.PickupIndex}:rejected", Kind = "pickup.rejected", FrameId = frameId, ZoneIndex = zone.ZoneIndex, TargetEntityIndex = entity.EntityIndex, PickupIndex = pickup.PickupIndex, ItemKey = pickup.Item?.ItemKey ?? "", ScalarValue = Math.Max(1, pickup.Item?.Quantity ?? 1) });
var dx = pickup.PositionX - entity.PositionX; var dz = pickup.PositionZ - entity.PositionZ;
var length = Math.Sqrt(dx * dx + dz * dz);
if (length < 0.001) { dx = 1; dz = 0; length = 1; }
pickup.VelocityX += dx / length * 25;
pickup.VelocityZ += dz / length * 25;
}
}
private static void StepCombat(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonIntentState intents,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings,
IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics,
AetheriaRuntimeCatalogSnapshot? catalog,
long frameId,
double simulationTimeSeconds)
{
var byIndex = entities.ToDictionary(entity => entity.EntityIndex);
foreach (var entity in entities)
RefreshShieldProjection(entity, catalog);
foreach (var attacker in entities)
{
var weaponGroup = ResolveFireGroup(run, zone, attacker, intents);
StepDeployableWeapons(run, zone, attacker, weaponGroup, deltaSeconds, settings, catalog, frameId);
StepChargedWeapons(run, zone, attacker, byIndex, weaponGroup, deltaSeconds,
settings, catalog, frameId);
StepConstantWeapons(run, zone, byIndex, attacker, weaponGroup, deltaSeconds,
settings, catalog, frameId);
var weapons = ResolveWeapons(attacker, weaponGroup, catalog, settings);
foreach (var weapon in weapons)
{
weapon.State.Firing = false;
if (weapon.State.Reloading)
{
weapon.State.ReloadProgress = Math.Max(0, weapon.State.ReloadProgress - deltaSeconds);
if (weapon.State.ReloadProgress <= 0)
{
weapon.State.Reloading = false;
weapon.State.Ammo = weapon.MagazineSize;
AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.completed");
}
}
weapon.State.CooldownProgress = Math.Max(0, weapon.State.CooldownProgress - deltaSeconds);
weapon.State.CoolingDown = weapon.State.CooldownProgress > 0;
}
if (!IsAlive(attacker) ||
attacker.TargetEntityIndex < 0 ||
!byIndex.TryGetValue(attacker.TargetEntityIndex, out var target) ||
!IsAlive(target) ||
!Hostile(attacker, target))
{
foreach (var weapon in weapons)
{
weapon.State.LockTargetEntityIndex = -1;
weapon.State.LockProgress = 0;
}
continue;
}
if (IsPlayerOwned(attacker) && weaponGroup < 0)
continue;
foreach (var weapon in weapons)
{
UpdateWeaponLock(attacker, target, weapon, deltaSeconds, settings);
if (DistanceSq(attacker, target) > weapon.Range * weapon.Range ||
weapon.State.LockProgress <= 0.99 ||
weapon.State.Reloading)
continue;
if (weapon.State.BurstRemaining <= 0)
{
if (weapon.State.CooldownProgress > 0)
continue;
if (weapon.SingleAmmoBurst)
{
var triggerResult = CommitWeaponRound(attacker, weapon, catalog);
if (triggerResult == WeaponRoundResult.ReloadStarted)
AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started");
PublishWeaponRoundResult(run, zone, attacker, weapon, frameId, triggerResult);
if (triggerResult != WeaponRoundResult.Fired) continue;
}
weapon.State.BurstRemaining = weapon.BurstCount;
weapon.State.BurstInterval = weapon.BurstTime / weapon.BurstCount;
weapon.State.BurstTimer = 0;
weapon.State.CoolingDown = true;
weapon.State.CooldownProgress = weapon.Cooldown;
}
weapon.State.BurstTimer += deltaSeconds;
while (weapon.State.BurstRemaining > 0 && weapon.State.BurstTimer > 0)
{
if (!weapon.SingleAmmoBurst)
{
var roundResult = CommitWeaponRound(attacker, weapon, catalog);
if (roundResult == WeaponRoundResult.ReloadStarted)
AppendWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started");
PublishWeaponRoundResult(run, zone, attacker, weapon, frameId, roundResult);
if (roundResult != WeaponRoundResult.Fired)
{
weapon.State.BurstRemaining = 0;
break;
}
}
weapon.State.BurstRemaining--;
weapon.State.BurstTimer -= weapon.State.BurstInterval;
var shotId = NextShotId(zone, attacker, weapon);
CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId, catalog, settings);
AppendShotCommittedEvent(run, zone, attacker, target, weapon, shotId, frameId);
weapon.State.Firing = true;
AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog,
weapon.State.OwnerIndex, weapon.Heat);
ApplyWeaponWear(attacker, weapon.State, 1);
}
}
}
PreparePhysicalPayloads(zone, byIndex, deltaSeconds);
var projectileStep = zone.PhysicalPayloads.Count == 0
? new AetheriaRuntimePhysicalPayloadStep(
Array.Empty<AetheriaRuntimePhysicalPayloadCommit>(),
Array.Empty<AetheriaRuntimePhysicalPayloadHit>())
: physicalPayloadPhysics.Step(zone, entities, deltaSeconds);
zone.PhysicalPayloads = projectileStep.PhysicalPayloads;
TriggerExpiredDeployables(run, zone, byIndex, frameId, simulationTimeSeconds);
foreach (var hit in projectileStep.Hits)
{
if (byIndex.TryGetValue(hit.TargetEntityIndex, out var target))
{
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{hit.Payload.PayloadId}:contact", Kind = "physical-payload.contact", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = hit.Payload.SourceEntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = hit.Payload.PayloadId, ItemKey = hit.Payload.PayloadKind, ScalarValue = hit.Payload.ContactMagnitude, PositionX = hit.PointX, PositionZ = hit.PointZ });
if (string.Equals(hit.Payload.PayloadKind, "mine", StringComparison.Ordinal) &&
hit.Payload.AgeSeconds >= hit.Payload.ActivationDelaySeconds &&
hit.Payload.TriggeredAtSeconds < 0)
{
hit.Payload.TriggeredAtSeconds = simulationTimeSeconds;
hit.Payload.TriggerReason = "proximity";
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{hit.Payload.PayloadId}:triggered", Kind = "deployable.triggered", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = hit.Payload.SourceEntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = hit.Payload.PayloadId, ItemKey = hit.Payload.WeaponItemKey, PositionX = hit.Payload.PositionX, PositionZ = hit.Payload.PositionZ });
}
}
}
ResolveDeployableDetonations(run, zone, entities, frameId, simulationTimeSeconds, catalog, settings);
foreach (var entity in entities)
{
AetheriaRuntimeEnergySimulation.StepRadiators(entity, catalog, deltaSeconds);
AetheriaRuntimeEnergySimulation.SettleReactors(entity, catalog, deltaSeconds);
AetheriaRuntimeThermalSimulation.Step(entity, deltaSeconds, catalog);
entity.IsActive = IsAlive(entity);
if (!entity.IsActive)
{
entity.VelocityX = 0;
entity.VelocityY = 0;
entity.TargetEntityIndex = -1;
}
}
}
private static void StepDeployableWeapons(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit attacker,
int weaponGroup,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings,
AetheriaRuntimeCatalogSnapshot? catalog,
long frameId)
{
foreach (var deployable in ResolveDeployableWeapons(attacker, weaponGroup, catalog, settings))
{
var weapon = deployable.Weapon;
weapon.State.Firing = false;
weapon.State.CooldownProgress = Math.Max(0, weapon.State.CooldownProgress - deltaSeconds);
weapon.State.CoolingDown = weapon.State.CooldownProgress > 0;
if (!IsAlive(attacker) || weaponGroup < 0 || weapon.State.CooldownProgress > 0) continue;
var result = CommitWeaponRound(attacker, weapon, catalog);
PublishWeaponRoundResult(run, zone, attacker, weapon, frameId, result);
if (result != WeaponRoundResult.Fired) continue;
var direction = Normalize(attacker.DirectionX, attacker.DirectionY);
if (Math.Abs(direction.X) + Math.Abs(direction.Y) < 0.0001) direction = (0, 1);
var payloadId = NextShotId(zone, attacker, weapon);
var payloads = (zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>()).ToList();
payloads.Add(new AetheriaRuntimePhysicalPayloadCommit
{
PayloadId = payloadId,
SourceEntityIndex = attacker.EntityIndex,
FactionKey = attacker.FactionKey ?? "",
PositionX = attacker.PositionX + direction.X * 2,
PositionY = attacker.PositionY,
PositionZ = attacker.PositionZ + direction.Y * 2,
DirectionX = direction.X,
DirectionY = direction.Y,
VelocityX = direction.X * weapon.ProjectileSpeed,
VelocityY = direction.Y * weapon.ProjectileSpeed,
Radius = 1,
LifetimeSeconds = deployable.LifetimeSeconds,
Active = true,
PayloadKind = "mine",
ActivationDelaySeconds = deployable.ActivationDelaySeconds,
TriggerRadius = deployable.TriggerRadius,
DetonationDelaySeconds = deployable.DetonationDelaySeconds,
BlastRadius = deployable.BlastRadius,
PayloadMagnitude = weapon.Damage,
MaximumSourceDistance = weapon.Range,
WeaponItemKey = weapon.ItemKey,
DamageType = weapon.DamageType,
Penetration = weapon.Penetration,
DamageSpread = weapon.DamageSpread
});
zone.PhysicalPayloads = payloads;
weapon.State.Firing = true;
weapon.State.CoolingDown = true;
weapon.State.CooldownProgress = weapon.Cooldown;
AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog,
weapon.State.OwnerIndex, weapon.Heat);
ApplyWeaponWear(attacker, weapon.State, 1);
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{payloadId}:deployed", Kind = "deployable.deployed", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex, SubjectKey = payloadId, ItemKey = weapon.ItemKey, PositionX = attacker.PositionX, PositionZ = attacker.PositionZ });
}
}
private static void ResolveDeployableDetonations(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
long frameId,
double simulationTimeSeconds,
AetheriaRuntimeCatalogSnapshot? catalog,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var survivors = new List<AetheriaRuntimePhysicalPayloadCommit>();
foreach (var payload in zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
{
if (!string.Equals(payload.PayloadKind, "mine", StringComparison.Ordinal) ||
payload.TriggeredAtSeconds < 0 ||
simulationTimeSeconds < payload.TriggeredAtSeconds + payload.DetonationDelaySeconds)
{
survivors.Add(payload);
continue;
}
foreach (var target in entities.Where(value => value.IsActive && value.EntityIndex != payload.SourceEntityIndex))
{
var dx = target.PositionX - payload.PositionX;
var dz = target.PositionZ - payload.PositionZ;
if (dx * dx + dz * dz > payload.BlastRadius * payload.BlastRadius) continue;
var source = entities.FirstOrDefault(value => value.EntityIndex == payload.SourceEntityIndex);
var aliveBefore = IsAlive(target);
var damage = ResolveDamage(target, payload.PayloadMagnitude, payload.DamageType,
payload.Penetration, payload.DamageSpread, source,
payload.PositionX, payload.PositionZ, true, catalog);
if (damage.ShieldAbsorbedDamage > 0)
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"physical-payload:{payload.PayloadId}:shield:{target.EntityIndex}",
Kind = "shield.absorbed", FrameId = frameId, ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = payload.SourceEntityIndex, TargetEntityIndex = target.EntityIndex,
SubjectKey = payload.PayloadId, ItemKey = payload.WeaponItemKey,
ScalarValue = damage.ShieldAbsorbedDamage,
AuxiliaryValue = damage.ShieldHeatGenerated,
PositionX = payload.PositionX, PositionZ = payload.PositionZ
});
if (damage.TotalAppliedDamage > 0)
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"physical-payload:{payload.PayloadId}:damage:{target.EntityIndex}",
Kind = "entity.damaged", FrameId = frameId, ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = payload.SourceEntityIndex, TargetEntityIndex = target.EntityIndex,
SubjectKey = payload.PayloadId, ItemKey = payload.WeaponItemKey,
ScalarValue = damage.TotalAppliedDamage,
AuxiliaryValue = damage.HullAppliedDamage,
Reason = payload.DamageType,
PositionX = payload.PositionX, PositionZ = payload.PositionZ
});
if (aliveBefore && !IsAlive(target))
CommitDestruction(run, zone, target, payload.SourceEntityIndex,
payload.PayloadId, payload.WeaponItemKey, frameId, settings);
}
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{payload.PayloadId}:detonated", Kind = "deployable.detonated", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = payload.SourceEntityIndex, SubjectKey = payload.PayloadId, ItemKey = payload.WeaponItemKey, Reason = payload.TriggerReason, ScalarValue = payload.PayloadMagnitude, PositionX = payload.PositionX, PositionZ = payload.PositionZ });
}
zone.PhysicalPayloads = survivors;
}
private static void StepChargedWeapons(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit attacker,
IReadOnlyDictionary<int, AetheriaRuntimeEntitySnapshotCommit> byIndex,
int requestedWeaponGroup,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings,
AetheriaRuntimeCatalogSnapshot? catalog,
long frameId)
{
foreach (var weapon in ResolveChargedWeapons(attacker, requestedWeaponGroup, catalog, settings))
{
var state = weapon.Base.State;
state.Firing = false;
state.CooldownProgress = Math.Max(0, state.CooldownProgress - deltaSeconds);
state.CoolingDown = state.CooldownProgress > 0;
if (state.Reloading)
{
state.ReloadProgress = Math.Max(0, state.ReloadProgress - deltaSeconds);
if (state.ReloadProgress <= 0)
{
state.Reloading = false;
state.Ammo = weapon.Base.MagazineSize;
AppendChargedEvent(run, zone, attacker, weapon, frameId, "weapon.reload.completed", "", state.Ammo);
}
}
if (weapon.Requested && IsAlive(attacker) && !state.Charging && !state.CoolingDown &&
!state.Reloading && state.BurstRemaining <= 0)
{
state.Charging = true;
state.Charged = false;
state.Charge = 0;
state.ChargeHoldSeconds = 0;
state.ChargeRiskChecks = 0;
state.ChargeMalfunctionRisk = 0;
state.LockTargetEntityIndex = -1;
state.LastRefusalReason = "";
AppendChargedEvent(run, zone, attacker, weapon, frameId, "weapon.charge.started", "committed", 0);
}
byIndex.TryGetValue(attacker.TargetEntityIndex, out var solutionTarget);
var hasSolution = solutionTarget != null && IsAlive(solutionTarget) && Hostile(attacker, solutionTarget) &&
DistanceSq(attacker, solutionTarget) <= weapon.Base.Range * weapon.Base.Range;
if (state.Charging)
{
var step = deltaSeconds / weapon.ChargeTime;
if (!state.Charged)
{
var energy = weapon.ChargeEnergy * step;
if (!CanSupplyEnergy(attacker, catalog, energy))
{
state.Charging = false;
state.Charge = 0;
PublishChargedRefusal(run, zone, attacker, weapon, frameId, "insufficient-charge-energy");
continue;
}
CommitEnergy(attacker, catalog, energy);
AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog,
weapon.Base.State.OwnerIndex, weapon.ChargeHeat * step);
}
state.Charge += step;
if (!state.Charged && state.Charge >= 1)
{
state.Charged = true;
state.ChargeHoldSeconds = 0;
AppendChargedEvent(run, zone, attacker, weapon, frameId, "weapon.charge.ready", "holding", state.Charge);
}
if (state.Charged && hasSolution)
{
state.LockTargetEntityIndex = solutionTarget!.EntityIndex;
state.Charging = false;
state.ChargeHoldSeconds = 0;
state.ChargeRiskChecks = 0;
state.ChargeMalfunctionRisk = 0;
TriggerChargedShot(run, zone, attacker, weapon, frameId, catalog);
}
else if (state.Charged)
{
state.ChargeHoldSeconds += deltaSeconds;
var grace = Math.Max(0, weapon.FailureCharge - 1) * weapon.ChargeTime;
var overdue = Math.Max(0, state.ChargeHoldSeconds - grace);
var dueChecks = (int)Math.Floor(overdue);
while (state.ChargeRiskChecks < dueChecks && state.Charging)
{
state.ChargeRiskChecks++;
state.ChargeMalfunctionRisk = Clamp01(state.ChargeRiskChecks / weapon.ChargeTime);
if (ChargedMalfunctionRoll(run.GenerationSeed, attacker.EntityIndex,
state.OwnerIndex, state.BehaviorIndex, state.ChargeRiskChecks) < state.ChargeMalfunctionRisk)
{
state.Charging = false;
state.Charged = false;
state.Charge = 0;
state.CoolingDown = true;
state.CooldownProgress = weapon.Base.Cooldown;
weapon.Item.Durability = Math.Max(0, weapon.Item.Durability - weapon.FailureDamage);
AppendChargedEvent(run, zone, attacker, weapon, frameId,
"weapon.charge.malfunctioned", "hold-risk", state.ChargeMalfunctionRisk);
}
}
}
}
if (state.BurstRemaining > 0)
byIndex.TryGetValue(state.LockTargetEntityIndex, out solutionTarget);
if (state.BurstRemaining <= 0 || solutionTarget == null || !IsAlive(solutionTarget))
continue;
var shot = weapon.CommittedShot();
state.BurstTimer += deltaSeconds;
while (state.BurstRemaining > 0 && state.BurstTimer > 0)
{
if (!shot.SingleAmmoBurst)
{
var result = CommitWeaponRound(attacker, shot, catalog);
PublishWeaponRoundResult(run, zone, attacker, shot, frameId, result);
if (result == WeaponRoundResult.ReloadStarted)
AppendWeaponEvent(run, zone, attacker, shot, frameId, "weapon.reload.started");
if (result != WeaponRoundResult.Fired) { state.BurstRemaining = 0; break; }
}
state.BurstRemaining--;
state.BurstTimer -= state.BurstInterval;
var shotId = NextShotId(zone, attacker, shot);
CommitShotResolution(run, zone, attacker, solutionTarget, shot, shotId, frameId, catalog, settings);
AppendShotCommittedEvent(run, zone, attacker, solutionTarget, shot, shotId, frameId);
state.Firing = true;
AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog,
shot.State.OwnerIndex, shot.Heat);
ApplyWeaponWear(attacker, shot.State, 1);
}
if (state.BurstRemaining <= 0)
{
state.Charged = false; state.Charge = 0; state.ChargeHoldSeconds = 0;
state.ChargeRiskChecks = 0; state.ChargeMalfunctionRisk = 0;
}
}
}
private static void TriggerChargedShot(AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit attacker,
ResolvedChargedWeapon weapon, long frameId, AetheriaRuntimeCatalogSnapshot? catalog)
{
var shot = weapon.CommittedShot();
if (shot.SingleAmmoBurst)
{
var result = CommitWeaponRound(attacker, shot, catalog);
PublishWeaponRoundResult(run, zone, attacker, shot, frameId, result);
if (result == WeaponRoundResult.ReloadStarted)
AppendWeaponEvent(run, zone, attacker, shot, frameId, "weapon.reload.started");
if (result != WeaponRoundResult.Fired) { weapon.Base.State.Charged = false; weapon.Base.State.Charge = 0; return; }
}
weapon.Base.State.BurstRemaining = shot.BurstCount;
weapon.Base.State.BurstInterval = shot.BurstTime / shot.BurstCount;
weapon.Base.State.BurstTimer = 0;
weapon.Base.State.CoolingDown = true;
weapon.Base.State.CooldownProgress = shot.Cooldown;
AppendChargedEvent(run, zone, attacker, weapon, frameId, "weapon.charge.committed", "solution-acquired", 1);
}
private static void PublishChargedRefusal(AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedChargedWeapon weapon, long frameId, string reason)
{
if (string.Equals(weapon.Base.State.LastRefusalReason, reason, StringComparison.Ordinal)) return;
weapon.Base.State.LastRefusalReason = reason;
AppendChargedEvent(run, zone, entity, weapon, frameId, "weapon.fire.refused", reason, weapon.Base.State.Ammo);
}
private static void AppendChargedEvent(AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedChargedWeapon weapon, long frameId, string kind, string subject, double scalar)
{
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:weapon:{weapon.Base.State.OwnerIndex}:{weapon.Base.State.BehaviorIndex}:{kind}",
Kind = kind, FrameId = frameId, ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = entity.EntityIndex, TargetEntityIndex = entity.TargetEntityIndex,
SubjectKey = subject, ItemKey = weapon.Base.ItemKey, ScalarValue = scalar
});
}
private static double ChargedMalfunctionRoll(uint seed, int entityIndex,
int ownerIndex, int behaviorIndex, int check)
{
unchecked
{
uint hash = seed ^ (uint)entityIndex * 2246822519u ^ (uint)ownerIndex * 3266489917u ^
(uint)behaviorIndex * 668265263u ^ (uint)check * 374761393u;
hash ^= hash >> 16; hash *= 2246822519u; hash ^= hash >> 13;
return (hash + 1.0) / (uint.MaxValue + 2.0);
}
}
private static void StepConstantWeapons(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyDictionary<int, AetheriaRuntimeEntitySnapshotCommit> byIndex,
AetheriaRuntimeEntitySnapshotCommit attacker,
int weaponGroup,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings,
AetheriaRuntimeCatalogSnapshot? catalog,
long frameId)
{
foreach (var weapon in ResolveConstantWeapons(attacker, weaponGroup, catalog, settings))
{
if (weapon.State.Reloading)
{
weapon.State.ReloadProgress = Math.Max(0, weapon.State.ReloadProgress - deltaSeconds);
if (weapon.State.ReloadProgress <= 0)
{
weapon.State.Reloading = false;
weapon.State.Ammo = weapon.MagazineSize;
AppendConstantWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.completed", "");
}
}
byIndex.TryGetValue(attacker.TargetEntityIndex, out var selectedTarget);
var validTarget = IsAlive(attacker) && weaponGroup >= 0 &&
attacker.TargetEntityIndex >= 0 && selectedTarget != null &&
IsAlive(selectedTarget) && Hostile(attacker, selectedTarget) &&
DistanceSq(attacker, selectedTarget) <= weapon.Range * weapon.Range;
var shouldFire = weapon.Selected && validTarget && !weapon.State.Reloading;
if (!shouldFire)
{
StopConstantWeapon(run, zone, attacker, weapon, frameId,
weapon.State.Reloading ? "reload" : "inactive");
continue;
}
if (!weapon.State.Firing)
{
weapon.State.Firing = true;
weapon.State.LastRefusalReason = "";
AppendConstantWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.firing.started", "");
}
var energy = weapon.Energy * deltaSeconds;
if (!CanSupplyEnergy(attacker, catalog, energy))
{
PublishConstantWeaponRefusal(run, zone, attacker, weapon, frameId, "insufficient-energy");
StopConstantWeapon(run, zone, attacker, weapon, frameId, "insufficient-energy");
continue;
}
CommitEnergy(attacker, catalog, energy);
if (!string.IsNullOrWhiteSpace(weapon.AmmoItemKey))
{
weapon.State.AmmoIntervalProgress -= deltaSeconds / weapon.AmmoIntervalDuration;
if (weapon.State.AmmoIntervalProgress < 0)
{
weapon.State.AmmoIntervalProgress = 1;
if (weapon.MagazineSize > 1 && weapon.State.Ammo > 0)
{
weapon.State.Ammo--;
}
else if (!AetheriaRuntimeCargoTransactions.TryFind(attacker, weapon.AmmoItemKey,
out var cargoIndex, out var x, out var y) ||
!AetheriaRuntimeCargoTransactions.TryRemoveQuantity(
attacker, cargoIndex, weapon.AmmoItemKey, x, y, 1, out _))
{
PublishConstantWeaponRefusal(run, zone, attacker, weapon, frameId, "no-ammunition");
continue;
}
else if (weapon.MagazineSize > 1)
{
weapon.State.Reloading = true;
weapon.State.ReloadProgress = weapon.ReloadTime;
AppendConstantWeaponEvent(run, zone, attacker, weapon, frameId, "weapon.reload.started", "");
StopConstantWeapon(run, zone, attacker, weapon, frameId, "reload");
continue;
}
}
}
weapon.State.LastRefusalReason = "";
AetheriaRuntimeThermalSimulation.AddHeatToEquipment(attacker, catalog,
weapon.State.OwnerIndex, weapon.Heat * deltaSeconds);
ApplyWeaponWear(attacker, weapon.State, deltaSeconds);
var shot = weapon.ResolutionShot(deltaSeconds);
var shotId = NextShotId(zone, attacker, shot);
CommitShotResolution(run, zone, attacker, selectedTarget!, shot, shotId, frameId, catalog, settings);
AppendShotCommittedEvent(run, zone, attacker, selectedTarget!, shot, shotId, frameId);
}
}
private static void StopConstantWeapon(
AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit entity, ResolvedConstantWeapon weapon,
long frameId, string reason)
{
if (!weapon.State.Firing) return;
weapon.State.Firing = false;
AppendConstantWeaponEvent(run, zone, entity, weapon, frameId, "weapon.firing.stopped", reason);
}
private static void PublishConstantWeaponRefusal(
AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit entity, ResolvedConstantWeapon weapon,
long frameId, string reason)
{
if (string.Equals(weapon.State.LastRefusalReason, reason, StringComparison.Ordinal)) return;
weapon.State.LastRefusalReason = reason;
AppendConstantWeaponEvent(run, zone, entity, weapon, frameId, "weapon.fire.refused", reason);
}
private static void AppendConstantWeaponEvent(
AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit entity, ResolvedConstantWeapon weapon,
long frameId, string kind, string subject)
{
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:weapon:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:{kind}",
Kind = kind, FrameId = frameId, ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = entity.EntityIndex, TargetEntityIndex = entity.TargetEntityIndex,
SubjectKey = subject, ItemKey = weapon.ItemKey, ScalarValue = weapon.State.Ammo
});
}
private static void AppendWeaponEvent(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedWeapon weapon,
long frameId,
string kind)
{
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:weapon:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:{kind}",
Kind = kind,
FrameId = frameId,
ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = entity.EntityIndex,
SubjectKey = weapon.ItemKey,
ItemKey = weapon.ItemKey,
ScalarValue = weapon.State.Ammo
});
}
private static void PublishWeaponRoundResult(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedWeapon weapon,
long frameId,
WeaponRoundResult result)
{
var reason = result == WeaponRoundResult.InsufficientEnergy ? "insufficient-energy" :
result == WeaponRoundResult.NoAmmo ? "no-ammunition" : "";
if (string.IsNullOrEmpty(reason))
{
weapon.State.LastRefusalReason = "";
return;
}
if (string.Equals(weapon.State.LastRefusalReason, reason, StringComparison.Ordinal))
return;
weapon.State.LastRefusalReason = reason;
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:weapon:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:refused:{reason}",
Kind = "weapon.fire.refused",
FrameId = frameId,
ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = entity.EntityIndex,
TargetEntityIndex = entity.TargetEntityIndex,
SubjectKey = reason,
ItemKey = weapon.ItemKey,
ScalarValue = weapon.State.Ammo
});
}
private static void UpdateWeaponLock(
AetheriaRuntimeEntitySnapshotCommit attacker,
AetheriaRuntimeEntitySnapshotCommit target,
ResolvedWeapon weapon,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var weaponState = weapon.State;
if (weaponState.LockTargetEntityIndex != target.EntityIndex)
{
weaponState.LockTargetEntityIndex = target.EntityIndex;
weaponState.LockProgress = 0;
}
var targetDirection = Normalize(target.PositionX - attacker.PositionX, target.PositionZ - attacker.PositionZ);
var lookDirection = Normalize(attacker.DirectionX, attacker.DirectionY);
var dot = Math.Max(-1.0, Math.Min(1.0,
targetDirection.X * lookDirection.X + targetDirection.Y * lookDirection.Y));
var angleDegrees = Math.Acos(dot) * 180.0 / Math.PI;
if (angleDegrees >= weapon.LockAngleDegrees)
{
weaponState.LockProgress = Clamp01(
weaponState.LockProgress - deltaSeconds * weapon.LockDecayPerSecond);
return;
}
var contact = (attacker.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.FirstOrDefault(candidate => candidate != null && candidate.TargetEntityIndex == target.EntityIndex);
var information = Clamp01(contact?.InfoGathered ?? 0);
var directionalQuality = Math.Max(0, 1.0 - angleDegrees / 90.0);
var acquisition =
Math.Pow(directionalQuality, weapon.LockDirectionImpact) *
deltaSeconds *
weapon.LockSpeed *
Math.Pow(information, weapon.LockSensorImpact);
weaponState.LockProgress = Clamp01(weaponState.LockProgress + acquisition);
}
private static void PreparePhysicalPayloads(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyDictionary<int, AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
var active = new List<AetheriaRuntimePhysicalPayloadCommit>();
foreach (var projectile in zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
{
if (projectile == null || !projectile.Active)
continue;
projectile.AgeSeconds += deltaSeconds;
if (!string.Equals(projectile.PayloadKind, "mine", StringComparison.Ordinal) &&
projectile.AgeSeconds >= projectile.LifetimeSeconds)
{
continue;
}
if (string.Equals(projectile.PayloadKind, "mine", StringComparison.Ordinal) &&
projectile.AgeSeconds >= projectile.ActivationDelaySeconds)
{
projectile.Radius = Math.Max(1, projectile.TriggerRadius);
projectile.Stationary = true;
projectile.VelocityX = 0;
projectile.VelocityY = 0;
}
if (projectile.Guided &&
projectile.TargetEntityIndex >= 0 &&
entities.TryGetValue(projectile.TargetEntityIndex, out var target) &&
IsAlive(target))
{
GuidePhysicalPayload(projectile, target);
}
active.Add(projectile);
}
zone.PhysicalPayloads = active;
}
private static void TriggerExpiredDeployables(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyDictionary<int, AetheriaRuntimeEntitySnapshotCommit> entities,
long frameId,
double simulationTimeSeconds)
{
foreach (var payload in zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
{
if (!string.Equals(payload.PayloadKind, "mine", StringComparison.Ordinal) ||
payload.TriggeredAtSeconds >= 0)
continue;
var expired = payload.AgeSeconds >= payload.LifetimeSeconds;
var outOfRange = payload.MaximumSourceDistance > 0 &&
entities.TryGetValue(payload.SourceEntityIndex, out var source) &&
PositionDistanceSq(payload.PositionX, payload.PositionZ, source.PositionX, source.PositionZ) >
payload.MaximumSourceDistance * payload.MaximumSourceDistance;
if (!expired && !outOfRange)
continue;
payload.TriggeredAtSeconds = simulationTimeSeconds;
payload.DetonationDelaySeconds = 0;
payload.TriggerReason = expired ? "lifetime" : "range";
payload.Stationary = true;
payload.VelocityX = 0;
payload.VelocityY = 0;
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"physical-payload:{payload.PayloadId}:expired",
Kind = "deployable.expired",
FrameId = frameId,
ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = payload.SourceEntityIndex,
SubjectKey = payload.PayloadId,
ItemKey = payload.WeaponItemKey,
Reason = payload.TriggerReason,
PositionX = payload.PositionX,
PositionZ = payload.PositionZ
});
}
}
private static double PositionDistanceSq(double leftX, double leftZ, double rightX, double rightZ)
{
var dx = leftX - rightX;
var dz = leftZ - rightZ;
return dx * dx + dz * dz;
}
private static void GuidePhysicalPayload(
AetheriaRuntimePhysicalPayloadCommit projectile,
AetheriaRuntimeEntitySnapshotCommit target)
{
var speed = Math.Sqrt(
projectile.VelocityX * projectile.VelocityX +
projectile.VelocityY * projectile.VelocityY);
if (speed <= 0.0001)
return;
var x = target.PositionX - projectile.PositionX;
var y = target.PositionZ - projectile.PositionZ;
var magnitude = Math.Sqrt(x * x + y * y);
if (magnitude <= 0.0001)
return;
projectile.DirectionX = x / magnitude;
projectile.DirectionY = y / magnitude;
projectile.VelocityX = projectile.DirectionX * speed;
projectile.VelocityY = projectile.DirectionY * speed;
}
private static int ResolveFireGroup(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit attacker,
AetheriaRuntimeDaemonIntentState intents)
{
var intent = (intents == null
? Enumerable.Empty<AetheriaRuntimeDaemonWeaponGroupIntent>()
: intents.WeaponGroups)
.LastOrDefault(intent => intent != null &&
ActorMatches(intent.ActorEntityKey, zone.ZoneIndex, attacker.EntityIndex) &&
intent.Fire &&
intent.Active);
return intent?.WeaponGroup ?? (IsPlayerOwned(attacker) ? -1 : 0);
}
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 IReadOnlyList<ResolvedConstantWeapon> ResolveConstantWeapons(
AetheriaRuntimeEntitySnapshotCommit entity,
int weaponGroup,
AetheriaRuntimeCatalogSnapshot? catalog,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var equipmentIndices = weaponGroup >= 0 && weaponGroup < (entity.WeaponGroups ?? Array.Empty<IReadOnlyList<int>>()).Count
? entity.WeaponGroups[weaponGroup] ?? Array.Empty<int>()
: Array.Empty<int>();
return AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.ConstantWeapon)
.Select(behavior =>
{
var magazineSize = Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13)));
var state = EnsureWeaponState(entity, AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind,
behavior.EquipmentIndex, behavior.BehaviorIndex, behavior.Payload.Kind,
magazineSize > 1 ? magazineSize : 1,
settings);
return new ResolvedConstantWeapon(
state, behavior.Item.ItemKey, equipmentIndices.Contains(behavior.EquipmentIndex),
Math.Max(0, behavior.EvaluateStat(2)),
PositiveOr(behavior.EvaluateStat(6), 1),
Math.Max(0, behavior.EvaluateStat(9)),
Math.Max(0, behavior.EvaluateStat(10)),
ReadItemKey(behavior.Payload, 12), magazineSize,
PositiveOr(ReadNumber(behavior.Payload, 14), 1),
PositiveOr(ReadNumber(behavior.Payload, 17), 1),
Math.Max(0, behavior.EvaluateStat(5)),
Math.Max(0, behavior.EvaluateStat(15)));
})
.ToArray();
}
private static IReadOnlyList<ResolvedDeployableWeapon> ResolveDeployableWeapons(
AetheriaRuntimeEntitySnapshotCommit entity,
int weaponGroup,
AetheriaRuntimeCatalogSnapshot? catalog,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var equipmentIndices = weaponGroup >= 0 && weaponGroup < (entity.WeaponGroups ?? Array.Empty<IReadOnlyList<int>>()).Count
? entity.WeaponGroups[weaponGroup] ?? Array.Empty<int>()
: Array.Empty<int>();
return AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.DeployableWeapon)
.Where(behavior => equipmentIndices.Contains(behavior.EquipmentIndex))
.Select(behavior => new ResolvedDeployableWeapon(
ResolveAuthoredWeapon(entity, behavior, settings),
Math.Max(0, ReadNumber(behavior.Payload, 26)),
PositiveOr(ReadNumber(behavior.Payload, 27), 30),
PositiveOr(behavior.EvaluateStat(28), 25),
Math.Max(0, ReadNumber(behavior.Payload, 29)),
PositiveOr(behavior.EvaluateStat(30), 25)))
.ToArray();
}
private static void CommitShotResolution(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit attacker,
AetheriaRuntimeEntitySnapshotCommit target,
ResolvedWeapon weapon,
string shotId,
long frameId,
AetheriaRuntimeCatalogSnapshot? catalog,
AetheriaRuntimeDaemonSimulationSettings settings)
{
if ((run.ShotReceipts ?? Array.Empty<AetheriaRuntimeShotReceiptCommit>())
.Any(value => value != null && string.Equals(value.ShotId, shotId, StringComparison.Ordinal)))
return;
var contact = (attacker.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.FirstOrDefault(value => value != null && value.TargetEntityIndex == target.EntityIndex);
var information = Clamp01(contact?.InfoGathered ?? 0);
var lockQuality = Clamp01(weapon.State.LockProgress);
var distance = Math.Sqrt(DistanceSq(attacker, target));
var rangeSpan = Math.Max(0.001, weapon.Range - weapon.MinRange);
var normalizedRange = Clamp01((distance - weapon.MinRange) / rangeSpan);
var rangeFactor = distance < weapon.MinRange || distance > weapon.Range ? 0 : 1 - normalizedRange * 0.35;
var dx = target.PositionX - attacker.PositionX;
var dz = target.PositionZ - attacker.PositionZ;
var length = Math.Max(0.001, Math.Sqrt(dx * dx + dz * dz));
var relativeX = target.VelocityX - attacker.VelocityX;
var relativeZ = target.VelocityY - attacker.VelocityY;
var transverse = Math.Abs(relativeX * (-dz / length) + relativeZ * (dx / length));
var motionFactor = 1.0 / (1.0 + transverse / Math.Max(1, weapon.ProjectileSpeed));
var dispersionFactor = 1.0 / (1.0 + weapon.Spread);
var probability = weapon.Spread <= 0
? 1
: Clamp01(information * (0.5 + 0.5 * lockQuality) * rangeFactor * motionFactor * dispersionFactor);
var roll = ShotRoll(run.GenerationSeed, shotId, "hit");
var hit = roll < probability;
var angleRoll = ShotRoll(run.GenerationSeed, shotId, "impact-angle");
var radiusRoll = ShotRoll(run.GenerationSeed, shotId, "impact-radius");
var angle = angleRoll * Math.PI * 2;
var targetRadius = string.Equals(target.Kind, "station", StringComparison.OrdinalIgnoreCase) ? 48.0 : 20.0;
var impactRadius = hit
? Math.Sqrt(radiusRoll) * targetRadius
: targetRadius + (0.25 + radiusRoll) * Math.Max(targetRadius, weapon.Spread * distance);
var endpointX = target.PositionX + Math.Cos(angle) * impactRadius;
var endpointZ = target.PositionZ + Math.Sin(angle) * impactRadius;
var presentationKind = ResolveShotPresentationKind(weapon.State.BehaviorKind);
var aliveBefore = IsAlive(target);
var damage = hit ? ResolveDamage(target, weapon.Damage, weapon.DamageType,
weapon.Penetration, weapon.DamageSpread, attacker, endpointX, endpointZ, false, catalog) : DamageResolution.None;
AetheriaRuntimeShotReceipts.Append(run, new AetheriaRuntimeShotReceiptCommit
{
ShotId = shotId, FrameId = frameId, ZoneIndex = zone.ZoneIndex,
SourceEntityIndex = attacker.EntityIndex, TargetEntityIndex = target.EntityIndex,
WeaponItemKey = weapon.ItemKey, WeaponOwnerIndex = weapon.State.OwnerIndex,
WeaponBehaviorIndex = weapon.State.BehaviorIndex, ContactInformation = information,
LockQuality = lockQuality, RangeFactor = rangeFactor, MotionFactor = motionFactor,
DispersionFactor = dispersionFactor, HitProbability = probability, HitRoll = roll,
Hit = hit, NominalDamage = weapon.Damage, AppliedDamage = damage.TotalAppliedDamage,
Outcome = !hit ? "miss" : damage.ShieldAbsorbedDamage > 0 ? "shielded" : "hit",
OriginX = attacker.PositionX, OriginZ = attacker.PositionZ,
EndpointX = endpointX, EndpointZ = endpointZ,
PresentationDurationSeconds = presentationKind == "stream"
? 0.12
: distance / Math.Max(1, weapon.ProjectileSpeed),
PresentationKind = presentationKind,
ImpactAngleRoll = angleRoll, ImpactRadiusRoll = radiusRoll,
ImpactKind = !hit ? "none" : damage.ShieldAbsorbedDamage > 0 ? "shield" :
damage.ArmorAppliedDamage > 0 ? "armor" : damage.EquipmentAppliedDamage > 0 ? "equipment" : "hull",
PresentationIntensity = Math.Max(0.1, weapon.Damage),
ShieldAbsorbedDamage = damage.ShieldAbsorbedDamage,
HullAppliedDamage = damage.HullAppliedDamage,
ShieldEnergyConsumed = damage.ShieldEnergyConsumed,
ShieldHeatGenerated = damage.ShieldHeatGenerated,
DamageType = weapon.DamageType,
Penetration = weapon.Penetration,
DamageSpread = weapon.DamageSpread,
ArmorAppliedDamage = damage.ArmorAppliedDamage,
EquipmentAppliedDamage = damage.EquipmentAppliedDamage,
DamageCells = damage.Cells
});
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"shot:{shotId}:resolved", Kind = "shot.resolved", FrameId = frameId,
ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex,
TargetEntityIndex = target.EntityIndex, SubjectKey = hit ? "hit" : "miss",
ItemKey = weapon.ItemKey, ScalarValue = probability,
PositionX = target.PositionX, PositionZ = target.PositionZ
});
if (damage.ShieldAbsorbedDamage > 0)
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"shot:{shotId}:shield", Kind = "shield.absorbed", FrameId = frameId,
ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex,
TargetEntityIndex = target.EntityIndex, SubjectKey = shotId,
ItemKey = weapon.ItemKey, ScalarValue = damage.ShieldAbsorbedDamage,
PositionX = endpointX, PositionZ = endpointZ
});
if (damage.TotalAppliedDamage > 0)
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"shot:{shotId}:damage", Kind = "entity.damaged", FrameId = frameId,
ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex,
TargetEntityIndex = target.EntityIndex, SubjectKey = shotId,
ItemKey = weapon.ItemKey, ScalarValue = damage.TotalAppliedDamage,
AuxiliaryValue = damage.HullAppliedDamage, Reason = weapon.DamageType,
PositionX = target.PositionX, PositionZ = target.PositionZ
});
if (aliveBefore && !IsAlive(target))
CommitDestruction(run, zone, target, attacker.EntityIndex, shotId, weapon.ItemKey, frameId, settings);
}
private static string NextShotId(AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit attacker, ResolvedWeapon weapon)
{
weapon.State.ShotSequence++;
return $"shot:{zone.ZoneIndex}:{attacker.EntityIndex}:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:{weapon.State.ShotSequence}";
}
private static void CommitDestruction(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
AetheriaRuntimeEntitySnapshotCommit target,
int sourceEntityIndex,
string subjectKey,
string weaponItemKey,
long frameId,
AetheriaRuntimeDaemonSimulationSettings settings)
{
if (!string.IsNullOrWhiteSpace(target.DestructionId)) return;
var destructionId = $"destruction:{run.RunId}:{zone.ZoneIndex}:{target.EntityIndex}:{frameId}";
target.DestructionId = destructionId;
target.DestroyedFrameId = frameId;
target.IsActive = false;
target.TargetEntityIndex = -1;
target.TractorPower = 0;
target.TractorTargetPower = 0;
foreach (var state in target.WeaponStates ?? Array.Empty<AetheriaRuntimeWeaponStateCommit>())
{
state.Firing = false;
state.Charging = false;
state.Charged = false;
state.LockTargetEntityIndex = -1;
}
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>()).ToList();
var nextPickupIndex = pickups.Count == 0 ? 0 : pickups.Max(value => value.PickupIndex) + 1;
void Drop(AetheriaRuntimeLoadoutItemCommit item, string sourceKind, int sourceIndex)
{
var velocity = DestructionVelocity(run.GenerationSeed, destructionId,
$"{sourceKind}:{sourceIndex}", settings.LootDropVelocity);
var pickup = new AetheriaRuntimeDroppedPickupCommit
{
PickupIndex = nextPickupIndex++, PositionX = target.PositionX, PositionY = target.PositionY,
PositionZ = target.PositionZ, VelocityX = velocity.X, VelocityY = velocity.Y,
VelocityZ = velocity.Z, Item = CloneItem(item), AgeSeconds = 0,
LifetimeSeconds = settings.PickupLifetimeSeconds
};
pickups.Add(pickup);
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"{destructionId}:pickup:{pickup.PickupIndex}", Kind = "pickup.dropped",
FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex,
TargetEntityIndex = target.EntityIndex, PickupIndex = pickup.PickupIndex,
ItemKey = pickup.Item.ItemKey, ScalarValue = Math.Max(1, pickup.Item.Quantity),
SubjectKey = destructionId, Reason = sourceKind,
PositionX = target.PositionX, PositionZ = target.PositionZ
});
}
if (!string.IsNullOrWhiteSpace(target.HullItemKey) &&
ShotRoll(run.GenerationSeed, destructionId, "equipment:hull") < settings.LootDropProbability)
Drop(new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = target.HullItemKey, Quality = 1, Durability = 0,
Quantity = 1, Enabled = false
}, "equipment", -1);
var equipment = target.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
for (var index = 0; index < equipment.Count; index++)
{
var slot = equipment[index];
if (slot?.Item == null ||
ShotRoll(run.GenerationSeed, destructionId, $"equipment:{index}") >= settings.LootDropProbability)
continue;
Drop(slot.Item, "equipment", index);
}
var cargoIndex = 0;
foreach (var slot in (target.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>())
.Where(bay => bay != null)
.SelectMany(bay => bay.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()))
{
if (slot?.Item != null) Drop(slot.Item, "cargo", cargoIndex);
cargoIndex++;
}
zone.DroppedPickups = pickups;
target.Equipment = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
target.CargoContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
target.CargoBays = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
target.DockingBays = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
target.DockingBayContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
target.DockingBayAssignments = Array.Empty<int>();
foreach (var entity in zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
{
if (entity == null || entity.EntityIndex == target.EntityIndex) continue;
if (entity.TargetEntityIndex == target.EntityIndex) entity.TargetEntityIndex = -1;
entity.ChildEntityIndices = (entity.ChildEntityIndices ?? Array.Empty<int>())
.Where(index => index != target.EntityIndex).ToArray();
entity.DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty<int>())
.Select(index => index == target.EntityIndex ? -1 : index).ToArray();
entity.Contacts = (entity.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.Where(contact => contact != null && contact.TargetEntityIndex != target.EntityIndex).ToArray();
}
if (run.CurrentZoneIndex == zone.ZoneIndex && TryParseEntityIndex(run.CurrentEntityKey, out var currentIndex) &&
currentIndex == target.EntityIndex)
run.CurrentEntityKey = "";
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = destructionId, Kind = "entity.destroyed", FrameId = frameId,
ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex,
TargetEntityIndex = target.EntityIndex, SubjectKey = subjectKey,
ItemKey = weaponItemKey, PositionX = target.PositionX, PositionZ = target.PositionZ
});
}
private static AetheriaRuntimeLoadoutItemCommit CloneItem(AetheriaRuntimeLoadoutItemCommit item) => new()
{
ItemKey = item.ItemKey ?? "", Quality = item.Quality, Durability = item.Durability,
Quantity = item.Quantity, Enabled = item.Enabled, OverrideShutdown = item.OverrideShutdown,
Temperature = item.Temperature
};
private static (double X, double Y, double Z) DestructionVelocity(
uint seed, string destructionId, string itemKey, double speed)
{
var z = ShotRoll(seed, destructionId, $"{itemKey}:velocity-z") * 2 - 1;
var angle = ShotRoll(seed, destructionId, $"{itemKey}:velocity-angle") * Math.PI * 2;
var radius = Math.Sqrt(Math.Max(0, 1 - z * z));
return (Math.Cos(angle) * radius * speed, z * speed, Math.Sin(angle) * radius * speed);
}
private static void AppendShotCommittedEvent(AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit attacker,
AetheriaRuntimeEntitySnapshotCommit target, ResolvedWeapon weapon, string shotId, long frameId)
{
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
{
EventId = $"shot:{shotId}:committed", Kind = "shot.committed", FrameId = frameId,
ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex,
TargetEntityIndex = target.EntityIndex, SubjectKey = shotId, ItemKey = weapon.ItemKey,
ScalarValue = weapon.Damage, PositionX = attacker.PositionX, PositionZ = attacker.PositionZ
});
}
private static double ShotRoll(uint seed, string shotId, string salt)
{
unchecked
{
uint hash = seed ^ 2166136261u;
foreach (var c in (shotId ?? "") + ":" + (salt ?? "")) hash = (hash ^ c) * 16777619u;
hash ^= hash >> 16; hash *= 2246822519u; hash ^= hash >> 13;
return (hash + 1.0) / (uint.MaxValue + 2.0);
}
}
private static IReadOnlyList<ResolvedChargedWeapon> ResolveChargedWeapons(
AetheriaRuntimeEntitySnapshotCommit entity, int requestedWeaponGroup,
AetheriaRuntimeCatalogSnapshot? catalog, AetheriaRuntimeDaemonSimulationSettings settings)
{
var requestedEquipment = requestedWeaponGroup >= 0 && requestedWeaponGroup <
(entity.WeaponGroups ?? Array.Empty<IReadOnlyList<int>>()).Count
? entity.WeaponGroups[requestedWeaponGroup] ?? Array.Empty<int>() : Array.Empty<int>();
return AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.ChargedWeapon)
.Where(value => string.Equals(value.Payload.Kind, AetheriaRuntimeBehaviorKinds.ChargedWeapon, StringComparison.Ordinal))
.Select(value => new ResolvedChargedWeapon(
ResolveAuthoredWeapon(entity, value, settings), value.Item,
requestedEquipment.Contains(value.EquipmentIndex), PositiveOr(value.EvaluateStat(21), 1),
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)))
.ToArray();
}
private static IReadOnlyList<ResolvedWeapon> ResolveWeapons(
AetheriaRuntimeEntitySnapshotCommit entity,
int weaponGroup,
AetheriaRuntimeCatalogSnapshot? catalog,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var equipmentIndices = weaponGroup >= 0 && weaponGroup < (entity.WeaponGroups ?? Array.Empty<IReadOnlyList<int>>()).Count
? entity.WeaponGroups[weaponGroup] ?? Array.Empty<int>()
: Array.Empty<int>();
var authored = AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.InstantWeapon)
.Where(behavior => !string.Equals(behavior.Payload.Kind, AetheriaRuntimeBehaviorKinds.ChargedWeapon, StringComparison.Ordinal))
.Where(behavior => !string.Equals(behavior.Payload.Kind, AetheriaRuntimeBehaviorKinds.DeployableWeapon, StringComparison.Ordinal))
.Where(behavior => equipmentIndices.Contains(behavior.EquipmentIndex))
.Select(behavior => ResolveAuthoredWeapon(entity, behavior, settings))
.ToArray();
if (catalog != null)
return authored;
return new[] { ResolveFallbackWeapon(entity, settings) };
}
private static ResolvedWeapon ResolveAuthoredWeapon(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeEquippedBehavior behavior,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var state = EnsureWeaponState(entity, AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind,
behavior.EquipmentIndex, behavior.BehaviorIndex, behavior.Payload.Kind,
Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))), settings);
return new ResolvedWeapon(
state,
behavior.Item.ItemKey,
PositiveOr(behavior.EvaluateStat(2), ResolveProjectileDamage(entity, settings)) /
Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
PositiveOr(behavior.EvaluateStat(6), settings.AttackRange),
PositiveOr(behavior.EvaluateStat(19), settings.WeaponCooldownSeconds),
PositiveOr(behavior.EvaluateStat(16), settings.ProjectileSpeed),
Math.Max(0, behavior.EvaluateStat(10)) / Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
Math.Max(0, behavior.EvaluateStat(9)) / Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
ReadItemKey(behavior.Payload, 12),
Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))),
PositiveOr(ReadNumber(behavior.Payload, 14), settings.WeaponCooldownSeconds),
Math.Max(1, (int)Math.Round(behavior.EvaluateStat(17))),
Math.Max(0, behavior.EvaluateStat(18)),
ReadBool(behavior.Payload, 20),
PositiveOr(behavior.EvaluateStat(21), settings.WeaponLockSpeed),
Math.Max(0, behavior.EvaluateStat(22)),
PositiveOr(behavior.EvaluateStat(23), settings.WeaponLockAngleDegrees),
PositiveOr(behavior.EvaluateStat(24), settings.WeaponLockDirectionImpact),
Math.Max(0, behavior.EvaluateStat(25)),
Math.Max(0, behavior.EvaluateStat(5)),
Math.Max(0, behavior.EvaluateStat(15)),
ReadEnumName(behavior.Payload, 1, "Kinetic"),
Math.Max(0, behavior.EvaluateStat(3)),
Math.Max(0, behavior.EvaluateStat(4)));
}
private static ResolvedWeapon ResolveFallbackWeapon(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var state = EnsureWeaponState(entity, "daemon-simulation", entity.EntityIndex, 0, "ProjectileWeapon", -1, settings);
return new ResolvedWeapon(state,
IsPlayerOwned(entity) ? "vanguard-bolt" : "raider-bolt",
ResolveProjectileDamage(entity, settings), settings.AttackRange, settings.WeaponCooldownSeconds,
settings.ProjectileSpeed, ResolveProjectileDamage(entity, settings) * settings.ProjectileHeatScale,
0, "", -1, settings.WeaponCooldownSeconds,
1, 0, false,
settings.WeaponLockSpeed, settings.WeaponLockSensorImpact, settings.WeaponLockAngleDegrees,
settings.WeaponLockDirectionImpact, settings.WeaponLockDecayPerSecond,
0, 0, "Kinetic", 0, 0);
}
private static AetheriaRuntimeWeaponStateCommit EnsureWeaponState(
AetheriaRuntimeEntitySnapshotCommit entity,
string ownerKind,
int ownerIndex,
int behaviorIndex,
string behaviorKind,
int initialAmmo,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var states = (entity.WeaponStates ?? Array.Empty<AetheriaRuntimeWeaponStateCommit>()).ToList();
var state = states.FirstOrDefault(candidate =>
candidate != null &&
string.Equals(candidate.OwnerKind, ownerKind, StringComparison.Ordinal) &&
candidate.OwnerIndex == ownerIndex &&
candidate.BehaviorIndex == behaviorIndex);
if (state != null)
return state;
state = new AetheriaRuntimeWeaponStateCommit
{
OwnerKind = ownerKind,
OwnerIndex = ownerIndex,
BehaviorIndex = behaviorIndex,
BehaviorKind = behaviorKind,
Ammo = initialAmmo,
BurstRemaining = 0,
BurstInterval = settings.WeaponCooldownSeconds,
LockTargetEntityIndex = -1
};
states.Add(state);
entity.WeaponStates = states.ToArray();
return state;
}
private static double ResolveProjectileDamage(
AetheriaRuntimeEntitySnapshotCommit attacker,
AetheriaRuntimeDaemonSimulationSettings settings)
{
return IsPlayerOwned(attacker) ? settings.PawnProjectileDamage : settings.RaiderProjectileDamage;
}
private static double PositiveOr(double value, double fallback) =>
double.IsFinite(value) && value > 0 ? value : fallback;
private static string ResolveShotPresentationKind(string behaviorKind)
{
if (string.Equals(behaviorKind, AetheriaRuntimeBehaviorKinds.GuidedWeapon, StringComparison.Ordinal))
return "guided";
if (string.Equals(behaviorKind, AetheriaRuntimeBehaviorKinds.ConstantWeapon, StringComparison.Ordinal))
return "stream";
if (string.Equals(behaviorKind, AetheriaRuntimeBehaviorKinds.Launcher, StringComparison.Ordinal))
return "bolt";
return "bolt";
}
private static string ReadItemKey(AetheriaRuntimeBehaviorPayload payload, int key)
{
var value = (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value;
return value?.ItemKeyValue ?? value?.StringValue ?? "";
}
private static double ReadNumber(AetheriaRuntimeBehaviorPayload payload, int key)
{
return (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value?.NumberValue ?? 0;
}
private static bool ReadBool(AetheriaRuntimeBehaviorPayload payload, int key)
{
return (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value?.BoolValue ?? false;
}
private static string ReadEnumName(AetheriaRuntimeBehaviorPayload payload, int key, string fallback)
{
var value = (payload.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
.FirstOrDefault(field => field != null && field.Key == key)?.Value;
if (!string.IsNullOrWhiteSpace(value?.StringValue)) return value.StringValue;
var names = new[] { "Kinetic", "Corrosive", "Electric", "Thermal", "Optical", "Ionizing" };
var index = value == null ? -1 : (int)Math.Round(value.NumberValue);
return index >= 0 && index < names.Length ? names[index] : fallback;
}
private static WeaponRoundResult CommitWeaponRound(
AetheriaRuntimeEntitySnapshotCommit entity,
ResolvedWeapon weapon,
AetheriaRuntimeCatalogSnapshot? catalog)
{
if (weapon.MagazineSize > 1 && weapon.State.Ammo <= 0)
{
if (!string.IsNullOrWhiteSpace(weapon.AmmoItemKey))
{
if (!AetheriaRuntimeCargoTransactions.TryFind(entity, weapon.AmmoItemKey,
out var cargoIndex, out var x, out var y) ||
!AetheriaRuntimeCargoTransactions.TryRemoveQuantity(
entity, cargoIndex, weapon.AmmoItemKey, x, y, 1, out _))
return WeaponRoundResult.NoAmmo;
}
weapon.State.Reloading = true;
weapon.State.ReloadProgress = weapon.ReloadTime;
return WeaponRoundResult.ReloadStarted;
}
if (!CanSupplyEnergy(entity, catalog, weapon.Energy))
return WeaponRoundResult.InsufficientEnergy;
CommitEnergy(entity, catalog, weapon.Energy);
if (weapon.MagazineSize > 1)
weapon.State.Ammo--;
return WeaponRoundResult.Fired;
}
private static bool CanSupplyEnergy(AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeCatalogSnapshot? catalog, double demand) =>
AetheriaRuntimeEnergySimulation.CanSupply(entity, catalog, demand);
private static void CommitEnergy(AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeCatalogSnapshot? catalog, double demand) =>
AetheriaRuntimeEnergySimulation.TryConsume(entity, catalog, demand);
private static void ApplyWeaponWear(AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeWeaponStateCommit state, double multiplier)
{
if (state == null || state.OwnerKind != AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind) return;
var wear = (entity.EquipmentStates ?? Array.Empty<AetheriaRuntimeEquipmentStateCommit>())
.FirstOrDefault(value => value != null && value.EquipmentIndex == state.OwnerIndex)?.Wear ?? 0;
AetheriaRuntimeThermalSimulation.ApplyWear(entity, state.OwnerIndex, wear * multiplier);
}
private enum WeaponRoundResult { Fired, ReloadStarted, InsufficientEnergy, NoAmmo }
private sealed class ResolvedWeapon
{
public ResolvedWeapon(AetheriaRuntimeWeaponStateCommit state, string itemKey, double damage, double range,
double cooldown, double projectileSpeed, double heat, double energy, string ammoItemKey,
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 penetration = 0, double damageSpread = 0)
{
State = state; ItemKey = itemKey; Damage = damage; Range = range; Cooldown = cooldown;
ProjectileSpeed = projectileSpeed; Heat = heat; Energy = energy; AmmoItemKey = ammoItemKey;
MagazineSize = magazineSize; ReloadTime = reloadTime; LockSpeed = lockSpeed;
BurstCount = burstCount; BurstTime = burstTime; SingleAmmoBurst = singleAmmoBurst;
LockSensorImpact = lockSensorImpact; LockAngleDegrees = lockAngleDegrees;
LockDirectionImpact = lockDirectionImpact; LockDecayPerSecond = lockDecayPerSecond;
MinRange = minRange; Spread = spread;
DamageType = damageType; Penetration = penetration; DamageSpread = damageSpread;
}
public AetheriaRuntimeWeaponStateCommit State { get; }
public string ItemKey { get; }
public double Damage { get; }
public double Range { get; }
public double Cooldown { get; }
public double ProjectileSpeed { get; }
public double Heat { get; }
public double Energy { get; }
public string AmmoItemKey { get; }
public int MagazineSize { get; }
public double ReloadTime { get; }
public int BurstCount { get; }
public double BurstTime { get; }
public bool SingleAmmoBurst { get; }
public double LockSpeed { get; }
public double LockSensorImpact { get; }
public double LockAngleDegrees { get; }
public double LockDirectionImpact { get; }
public double LockDecayPerSecond { get; }
public double MinRange { get; }
public double Spread { get; }
public string DamageType { get; }
public double Penetration { get; }
public double DamageSpread { get; }
}
private sealed class ResolvedConstantWeapon
{
public ResolvedConstantWeapon(AetheriaRuntimeWeaponStateCommit state, string itemKey,
bool selected, double damage, double range, double energy, double heat, 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;
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);
public AetheriaRuntimeWeaponStateCommit State { get; }
public string ItemKey { get; }
public bool Selected { get; }
public double Damage { get; }
public double Range { get; }
public double Energy { get; }
public double Heat { get; }
public string AmmoItemKey { get; }
public int MagazineSize { get; }
public double ReloadTime { get; }
public double AmmoIntervalDuration { get; }
public double MinRange { get; }
public double Spread { get; }
}
private sealed class ResolvedDeployableWeapon
{
public ResolvedDeployableWeapon(ResolvedWeapon weapon, double activationDelaySeconds,
double lifetimeSeconds, double triggerRadius, double detonationDelaySeconds, double blastRadius)
{
Weapon = weapon;
ActivationDelaySeconds = activationDelaySeconds;
LifetimeSeconds = lifetimeSeconds;
TriggerRadius = triggerRadius;
DetonationDelaySeconds = detonationDelaySeconds;
BlastRadius = blastRadius;
}
public ResolvedWeapon Weapon { get; }
public double ActivationDelaySeconds { get; }
public double LifetimeSeconds { get; }
public double TriggerRadius { get; }
public double DetonationDelaySeconds { get; }
public double BlastRadius { get; }
}
private sealed class ResolvedChargedWeapon
{
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)
{
Base = baseWeapon; Item = item; Requested = requested; ChargeTime = chargeTime;
ChargeEnergy = chargeEnergy; ChargeHeat = chargeHeat; FailureCharge = failureCharge;
FailureDamage = failureDamage; DamageMultiplier = damageMultiplier;
BurstMultiplier = burstMultiplier; VelocityMultiplier = velocityMultiplier;
HeatMultiplier = heatMultiplier;
}
public ResolvedWeapon CommittedShot()
{
var count = Math.Max(1, (int)Math.Round(Base.BurstCount * BurstMultiplier));
return new ResolvedWeapon(Base.State, Base.ItemKey,
Base.Damage * Base.BurstCount * DamageMultiplier / count, Base.Range, Base.Cooldown,
Base.ProjectileSpeed * VelocityMultiplier, Base.Heat * Base.BurstCount * HeatMultiplier / count,
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.MinRange, Base.Spread, Base.DamageType, Base.Penetration, Base.DamageSpread);
}
public ResolvedWeapon Base { get; }
public AetheriaRuntimeLoadoutItemCommit Item { get; }
public bool Requested { get; }
public double ChargeTime { get; }
public double ChargeEnergy { get; }
public double ChargeHeat { get; }
public double FailureCharge { get; }
public double FailureDamage { get; }
public double DamageMultiplier { get; }
public double BurstMultiplier { get; }
public double VelocityMultiplier { get; }
public double HeatMultiplier { get; }
}
private static void RefreshContacts(
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonSimulationSettings settings)
{
foreach (var observer in entities)
{
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 <= ResolveSensorRange(observer, settings);
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;
observer.Visibility = observer.IsActive ? ResolveSensorRange(observer, settings) : 0;
observer.VisibilitySourceCount = observer.IsActive ? 1 : 0;
}
}
private static void EnsureStats(
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonSimulationSettings settings,
AetheriaRuntimeCatalogSnapshot? catalog)
{
foreach (var entity in entities)
{
if (entity.StatGrids == null || entity.StatGrids.Count == 0)
{
entity.StatGrids = new[]
{
Stat(Hull, DefaultHull(entity, settings)),
Stat(Shield, DefaultShield(entity, settings)),
Stat(Heat, 0)
};
}
if (!entity.IsActive && GetStat(entity, Hull) > 0)
entity.IsActive = true;
EnsureArmorState(entity, catalog);
}
}
private static DamageResolution ResolveDamage(
AetheriaRuntimeEntitySnapshotCommit target,
double damage,
string damageType,
double penetration,
double spread,
AetheriaRuntimeEntitySnapshotCommit? source,
double impactX,
double impactZ,
bool splash,
AetheriaRuntimeCatalogSnapshot? catalog)
{
damage = Math.Max(0, damage);
if (damage <= 0)
return DamageResolution.None;
foreach (var shield in AetheriaRuntimeEquippedBehaviorQueries.Find(target, catalog, "Shield")
.Where(value => value.Item.Enabled))
{
var efficiency = Math.Max(0.000001, shield.EvaluateStat(1));
var energyUsage = Math.Max(0, shield.EvaluateStat(2));
var energyDemand = damage * energyUsage;
shield.State.ShieldEfficiency = efficiency;
shield.State.ShieldEnergyUsage = energyUsage;
if (!CanSupplyEnergy(target, catalog, energyDemand))
continue;
CommitEnergy(target, catalog, energyDemand);
var heat = damage / efficiency;
AetheriaRuntimeThermalSimulation.AddHeat(target, heat);
RefreshShieldProjection(target, catalog);
return new DamageResolution(damage, 0, 0, 0, energyDemand, heat,
Array.Empty<AetheriaRuntimeDamageCellCommit>());
}
EnsureArmorState(target, catalog);
var hull = catalog?.FindItem(target.HullItemKey ?? "");
var cells = ResolveDamageCells(target, hull, impactX, impactZ, source, penetration, spread, splash);
if (cells.Count == 0)
cells.Add((0, 0));
var armor = FindGrid(target, Armor);
var perCell = damage / cells.Count;
var armorApplied = 0.0;
var equipmentApplied = 0.0;
var hullRequested = 0.0;
var receipts = new List<AetheriaRuntimeDamageCellCommit>(cells.Count);
foreach (var cell in cells)
{
var remaining = perCell;
var armorTaken = ApplyGridDamage(armor, cell.X, cell.Y, remaining);
armorApplied += armorTaken;
remaining -= armorTaken;
var equipmentIndex = -1;
var equipmentTaken = 0.0;
if (remaining > 0.1)
{
var equipment = FindEquipmentAt(target, catalog, cell.X, cell.Y);
if (equipment.Slot != null)
{
equipmentIndex = equipment.Index;
equipmentTaken = Math.Min(Math.Max(0, equipment.Slot.Item.Durability), remaining);
equipment.Slot.Item.Durability = Math.Max(0, equipment.Slot.Item.Durability - equipmentTaken);
equipmentApplied += equipmentTaken;
remaining -= equipmentTaken;
}
}
var cellHull = remaining > 0 ? remaining : 0;
hullRequested += cellHull;
receipts.Add(new AetheriaRuntimeDamageCellCommit
{
X = cell.X, Y = cell.Y, ArmorAppliedDamage = armorTaken,
EquipmentIndex = equipmentIndex, EquipmentAppliedDamage = equipmentTaken,
HullAppliedDamage = cellHull
});
}
var hullBefore = Math.Max(0, GetStat(target, Hull));
var hullApplied = hullRequested > 0.1 ? Math.Min(hullBefore, hullRequested) : 0;
if (hullRequested > 0 && hullApplied < hullRequested)
foreach (var receipt in receipts)
receipt.HullAppliedDamage *= hullApplied / hullRequested;
SetStat(target, Hull, hullBefore - hullApplied);
return new DamageResolution(0, armorApplied, equipmentApplied, hullApplied, 0, 0, receipts);
}
private static void EnsureArmorState(AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeCatalogSnapshot? catalog)
{
var armorGrid = FindGrid(entity, Armor);
var maximumArmorGrid = FindGrid(entity, MaximumArmor);
if (armorGrid != null && maximumArmorGrid != null) return;
var hull = catalog?.FindItem(entity.HullItemKey ?? "");
if (hull == null || hull.ShapeWidth <= 0 || hull.ShapeHeight <= 0 || hull.ShapeCells == null || hull.ShapeCells.Count == 0)
return;
var values = new double[hull.ShapeWidth * hull.ShapeHeight];
foreach (var cell in hull.ShapeCells)
if (cell.X >= 0 && cell.Y >= 0 && cell.X < hull.ShapeWidth && cell.Y < hull.ShapeHeight)
values[cell.Y * hull.ShapeWidth + cell.X] = Math.Max(0, hull.HullArmor);
foreach (var hardpoint in hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
foreach (var cell in hardpoint.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
{
var x = hardpoint.PositionX + cell.X;
var y = hardpoint.PositionY + cell.Y;
if (x >= 0 && y >= 0 && x < hull.ShapeWidth && y < hull.ShapeHeight)
values[y * hull.ShapeWidth + x] += Math.Max(0, hardpoint.Armor);
}
var grids = (entity.StatGrids ?? Array.Empty<AetheriaRuntimeEntityStatGridCommit>()).ToList();
if (armorGrid == null)
grids.Add(new AetheriaRuntimeEntityStatGridCommit { Name = Armor, Width = hull.ShapeWidth, Height = hull.ShapeHeight, Values = values });
if (maximumArmorGrid == null)
grids.Add(new AetheriaRuntimeEntityStatGridCommit { Name = MaximumArmor, Width = hull.ShapeWidth, Height = hull.ShapeHeight, Values = values.ToArray() });
entity.StatGrids = grids;
}
private static List<(int X, int Y)> ResolveDamageCells(AetheriaRuntimeEntitySnapshotCommit target,
AetheriaRuntimeCatalogItem? hull, double impactX, double impactZ,
AetheriaRuntimeEntitySnapshotCommit? source, double penetration, double spread, bool splash)
{
var result = new HashSet<(int X, int Y)>();
if (hull == null || hull.ShapeCells == null || hull.ShapeCells.Count == 0) return result.ToList();
var forward = Normalize(target.DirectionX, target.DirectionY);
if (Math.Abs(forward.X) + Math.Abs(forward.Y) < 0.001) forward = (0, 1);
var right = (X: forward.Y, Y: -forward.X);
var occupied = new HashSet<(int X, int Y)>(hull.ShapeCells.Select(cell => (cell.X, cell.Y)));
if (splash && source != null)
{
var incoming = Normalize(target.PositionX - source.PositionX, target.PositionZ - source.PositionZ);
var localIncoming = (X: incoming.X * right.X + incoming.Y * right.Y,
Y: incoming.X * forward.X + incoming.Y * forward.Y);
var centerX = hull.ShapeCells.Average(cell => cell.X);
var centerY = hull.ShapeCells.Average(cell => cell.Y);
foreach (var cell in hull.ShapeCells)
if ((cell.X - centerX) * localIncoming.X + (cell.Y - centerY) * localIncoming.Y < 0)
result.Add((cell.X, cell.Y));
return result.OrderBy(cell => cell.Y).ThenBy(cell => cell.X).ToList();
}
var dx = impactX - target.PositionX;
var dz = impactZ - target.PositionZ;
var localX = dx * right.X + dz * right.Y;
var localY = dx * forward.X + dz * forward.Y;
var radius = string.Equals(target.Kind, "station", StringComparison.OrdinalIgnoreCase) ? 48.0 : 20.0;
var schematicX = (localX / (radius * 2) + 0.5) * Math.Max(1, hull.ShapeWidth - 1);
var schematicY = (localY / (radius * 2) + 0.5) * Math.Max(1, hull.ShapeHeight - 1);
var first = hull.ShapeCells.OrderBy(cell =>
(cell.X - schematicX) * (cell.X - schematicX) + (cell.Y - schematicY) * (cell.Y - schematicY)).First();
result.Add((first.X, first.Y));
var hitHardpoint = (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
.FirstOrDefault(hardpoint => (hardpoint.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
.Any(cell => hardpoint.PositionX + cell.X == first.X && hardpoint.PositionY + cell.Y == first.Y));
if (hitHardpoint != null)
foreach (var cell in hitHardpoint.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
if (occupied.Contains((hitHardpoint.PositionX + cell.X, hitHardpoint.PositionY + cell.Y)))
result.Add((hitHardpoint.PositionX + cell.X, hitHardpoint.PositionY + cell.Y));
for (var i = 0; i < (int)Math.Round(Math.Max(0, spread)); i++)
{
var expanded = result.ToArray();
foreach (var cell in expanded)
foreach (var step in new[] { (-1, 0), (1, 0), (0, -1), (0, 1) })
if (occupied.Contains((cell.X + step.Item1, cell.Y + step.Item2)))
result.Add((cell.X + step.Item1, cell.Y + step.Item2));
}
if (penetration > 0.5 && source != null)
{
var travel = Normalize(target.PositionX - source.PositionX, target.PositionZ - source.PositionZ);
var penetrationX = travel.X * right.X + travel.Y * right.Y;
var penetrationY = travel.X * forward.X + travel.Y * forward.Y;
var pointX = first.X + 0.5;
var pointY = first.Y + 0.5;
for (var distance = 0.5; distance < penetration; distance += 0.5)
{
pointX += penetrationX * 0.5;
pointY += penetrationY * 0.5;
var cell = ((int)Math.Floor(pointX), (int)Math.Floor(pointY));
if (occupied.Contains(cell)) result.Add(cell);
}
}
return result.OrderBy(cell => cell.Y).ThenBy(cell => cell.X).ToList();
}
private static (AetheriaRuntimeLoadoutItemSlotCommit? Slot, int Index) FindEquipmentAt(
AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeCatalogSnapshot? catalog, int x, int y)
{
var equipment = entity.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
for (var index = 0; index < equipment.Count; index++)
{
var slot = equipment[index];
var item = catalog?.FindItem(slot.Item?.ItemKey ?? "");
if (item?.ShapeCells == null) continue;
if (item.ShapeCells.Any(cell => slot.X + cell.X == x && slot.Y + cell.Y == y)) return (slot, index);
}
return (null, -1);
}
private static AetheriaRuntimeEntityStatGridCommit? FindGrid(AetheriaRuntimeEntitySnapshotCommit entity, string name) =>
(entity.StatGrids ?? Array.Empty<AetheriaRuntimeEntityStatGridCommit>())
.FirstOrDefault(candidate => string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase));
private static double ApplyGridDamage(AetheriaRuntimeEntityStatGridCommit? grid, int x, int y, double damage)
{
if (grid == null || x < 0 || y < 0 || x >= grid.Width || y >= grid.Height) return 0;
var values = (grid.Values ?? Array.Empty<double>()).ToArray();
var index = y * grid.Width + x;
if (index < 0 || index >= values.Length) return 0;
var applied = Math.Min(Math.Max(0, values[index]), Math.Max(0, damage));
values[index] -= applied;
grid.Values = values;
return applied;
}
private static void RefreshShieldProjection(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeCatalogSnapshot? catalog)
{
var shield = AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, "Shield")
.FirstOrDefault(value => value.Item.Enabled);
if (shield == null)
{
SetStat(entity, Shield, 0);
return;
}
var energyUsage = Math.Max(0, shield.EvaluateStat(2));
var availableEnergy = (entity.BehaviorStates ?? Array.Empty<AetheriaRuntimeBehaviorStateCommit>())
.Where(value => value != null && value.BehaviorKind == "Capacitor")
.Sum(value => Math.Max(0, value.CapacitorCharge));
SetStat(entity, Shield, energyUsage <= 0 ? 1 : availableEnergy / energyUsage);
}
private readonly struct DamageResolution
{
public static DamageResolution None { get; } = new DamageResolution(0, 0, 0, 0, 0, 0,
Array.Empty<AetheriaRuntimeDamageCellCommit>());
public DamageResolution(double shieldAbsorbedDamage, double armorAppliedDamage,
double equipmentAppliedDamage, double hullAppliedDamage,
double shieldEnergyConsumed, double shieldHeatGenerated,
IReadOnlyList<AetheriaRuntimeDamageCellCommit> cells)
{
ShieldAbsorbedDamage = shieldAbsorbedDamage;
ArmorAppliedDamage = armorAppliedDamage;
EquipmentAppliedDamage = equipmentAppliedDamage;
HullAppliedDamage = hullAppliedDamage;
ShieldEnergyConsumed = shieldEnergyConsumed;
ShieldHeatGenerated = shieldHeatGenerated;
Cells = cells;
}
public double ShieldAbsorbedDamage { get; }
public double ArmorAppliedDamage { get; }
public double EquipmentAppliedDamage { get; }
public double HullAppliedDamage { get; }
public double ShieldEnergyConsumed { get; }
public double ShieldHeatGenerated { get; }
public IReadOnlyList<AetheriaRuntimeDamageCellCommit> Cells { get; }
public double TotalAppliedDamage =>
ArmorAppliedDamage + EquipmentAppliedDamage + HullAppliedDamage;
}
private static bool IsAlive(AetheriaRuntimeEntitySnapshotCommit entity)
{
return entity.IsActive && GetStat(entity, Hull) > 0;
}
private static bool IsPlayerOwned(AetheriaRuntimeEntitySnapshotCommit entity)
{
return string.Equals(entity.FactionKey, "player", StringComparison.OrdinalIgnoreCase);
}
private static bool Hostile(
AetheriaRuntimeEntitySnapshotCommit left,
AetheriaRuntimeEntitySnapshotCommit right)
{
return IsPlayerOwned(left) != IsPlayerOwned(right) &&
(string.Equals(left.FactionKey, "raider", StringComparison.OrdinalIgnoreCase) ||
string.Equals(right.FactionKey, "raider", StringComparison.OrdinalIgnoreCase));
}
private static double ResolveSpeed(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeDaemonSimulationSettings settings)
{
if (string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase))
return 0;
return string.Equals(entity.FactionKey, "raider", StringComparison.OrdinalIgnoreCase)
? settings.RaiderSpeed
: settings.PawnSpeed;
}
private static double ResolveSensorRange(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeDaemonSimulationSettings settings)
{
return string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase)
? settings.StationSensorRange
: settings.EntitySensorRange;
}
private static double DefaultHull(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeDaemonSimulationSettings settings)
{
return string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase)
? (IsPlayerOwned(entity) ? settings.PlayerStationHull : settings.HostileStationHull)
: string.Equals(entity.FactionKey, "raider", StringComparison.OrdinalIgnoreCase)
? settings.RaiderEntityHull
: settings.PlayerEntityHull;
}
private static double DefaultShield(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeDaemonSimulationSettings settings)
{
return string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase)
? settings.StationShield
: settings.EntityShield;
}
private static double GetStat(AetheriaRuntimeEntitySnapshotCommit entity, string name)
{
var grid = (entity.StatGrids ?? Array.Empty<AetheriaRuntimeEntityStatGridCommit>())
.FirstOrDefault(candidate => string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase));
return grid?.Values?.FirstOrDefault() ?? 0;
}
private static void SetStat(AetheriaRuntimeEntitySnapshotCommit entity, string name, double value)
{
var grids = (entity.StatGrids ?? Array.Empty<AetheriaRuntimeEntityStatGridCommit>()).ToList();
var index = grids.FindIndex(candidate => string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase));
var grid = Stat(name, value);
if (index >= 0)
grids[index] = grid;
else
grids.Add(grid);
entity.StatGrids = grids;
}
private static AetheriaRuntimeEntityStatGridCommit Stat(string name, double value)
{
return new AetheriaRuntimeEntityStatGridCommit
{
Name = name,
Width = 1,
Height = 1,
Values = new[] { value }
};
}
private static double DistanceSq(
AetheriaRuntimeEntitySnapshotCommit left,
AetheriaRuntimeEntitySnapshotCommit right)
{
var dx = right.PositionX - left.PositionX;
var dy = right.PositionZ - left.PositionZ;
return dx * dx + dy * dy;
}
private static (double X, double Y) Normalize(double x, double y)
{
var magnitude = Math.Sqrt(x * x + y * y);
return magnitude <= 0.0001 ? (0, 0) : (x / magnitude, y / magnitude);
}
private static void Face(AetheriaRuntimeEntitySnapshotCommit entity, double x, double y)
{
var direction = Normalize(x, y);
if (Math.Abs(direction.X) + Math.Abs(direction.Y) <= 0.0001)
return;
entity.DirectionX = direction.X;
entity.DirectionY = direction.Y;
}
private static double Clamp01(double value)
{
return value < 0 ? 0 : value > 1 ? 1 : value;
}
private static bool TryParseEntityIndex(string? entityKey, out int entityIndex)
{
entityIndex = -1;
if (string.IsNullOrWhiteSpace(entityKey))
return false;
var marker = entityKey.LastIndexOf(".entity.", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
return false;
var start = marker + ".entity.".Length;
var end = start;
while (end < entityKey.Length && char.IsDigit(entityKey[end]))
end++;
return end > start && int.TryParse(entityKey.Substring(start, end - start), out entityIndex);
}
}
}