Move projectile physics behind daemon Ymir authority

This commit is contained in:
Meta Vens 2026-07-11 17:43:48 +01:00
commit fe0c193724
12 changed files with 424 additions and 404 deletions

View file

@ -9,5 +9,6 @@
<ItemGroup>
<ProjectReference Include="..\Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" />
<ProjectReference Include="..\..\Ymir\src\Ymir.Core\Ymir.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -1,194 +1,125 @@
using Aetheria.State.Daemon;
using GameCult.Aetheria.State.Verse;
var checks = new AetheriaDaemonCombatKernelSmokeChecks();
var checks = new AetheriaDaemonYmirSmokeChecks();
checks.Run();
Console.WriteLine("Daemon combat kernel smoke passed.");
Console.WriteLine("Daemon Ymir projectile smoke passed.");
internal sealed class AetheriaDaemonCombatKernelSmokeChecks
internal sealed class AetheriaDaemonYmirSmokeChecks
{
public void Run()
{
DeterministicTicksProduceSameSnapshot();
ActionableUnresolvedTrackCanFireBeforeVisibility();
KernelWritesNativeSnapshotRows();
YmirMovesProjectileAndReportsStableContact();
DaemonSimulationAppliesYmirHit();
MissingPhysicsOwnerCannotAdvanceProjectiles();
}
private static void DeterministicTicksProduceSameSnapshot()
private static void YmirMovesProjectileAndReportsStableContact()
{
var left = Scenario();
var right = Scenario();
var settings = AetheriaDaemonCombatKernelSettings.Default;
var (run, zone, target) = Scenario();
var step = new AetheriaYmirProjectilePhysics().Step(zone, zone.Entities, 0.1);
for (var i = 0; i < 8; i++)
RequireEqual("ymir.core", new AetheriaYmirProjectilePhysics().AuthorityId, "adapter must identify its owner");
RequireEqual(0, step.Projectiles.Count, "contacted projectile must not survive");
RequireEqual(1, step.Hits.Count, "Ymir must report one projectile contact");
RequireEqual(target.EntityIndex, step.Hits[0].TargetEntityIndex, "contact must resolve the daemon entity");
RequireEqual("aetheria.projectile.smoke-projectile", step.Hits[0].ProjectileBodyId, "projectile body id must be stable");
RequireEqual("aetheria.daemon.entity.2", step.Hits[0].TargetBodyId, "entity body id must be stable");
}
private static void DaemonSimulationAppliesYmirHit()
{
var (run, _, target) = Scenario();
AetheriaRuntimeDaemonSimulation.Step(
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
new AetheriaYmirProjectilePhysics());
RequireEqual(88.0, Stat(target, "hull"), "Aetheria must interpret the Ymir contact as damage");
RequireEqual(0, run.Zones[0].Projectiles.Count, "spent projectile must leave daemon state");
}
private static void MissingPhysicsOwnerCannotAdvanceProjectiles()
{
var (run, _, _) = Scenario();
try
{
AetheriaDaemonCombatKernel.Step(left, null, 0.25, catalog: null, settings);
AetheriaDaemonCombatKernel.Step(right, null, 0.25, catalog: null, settings);
AetheriaRuntimeDaemonSimulation.Step(
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
AetheriaRuntimeProjectilePhysicsUnavailable.Instance);
}
catch (InvalidOperationException)
{
return;
}
RequireEqual(Snapshot(left), Snapshot(right), "combat kernel should be deterministic for the same native state");
throw new InvalidOperationException("daemon advanced a projectile without an authoritative physics owner");
}
private static void ActionableUnresolvedTrackCanFireBeforeVisibility()
private static (AetheriaRuntimeRunCheckpointCommit Run, AetheriaRuntimeZoneSnapshotCommit Zone, AetheriaRuntimeEntitySnapshotCommit Target) Scenario()
{
var run = Scenario(
attackerContactConfidence: 0.35,
targetShield: 0.0);
var settings = new AetheriaDaemonCombatKernelSettings
var source = Entity(1, 0, "player");
var target = Entity(2, 30, "raider");
var zone = new AetheriaRuntimeZoneSnapshotCommit
{
TrackResolutionPerSecond = 0.0,
LaunchTrackThreshold = 0.3,
VisibleTrackThreshold = 0.8,
AbstractWeaponRange = 500.0,
DefaultWeaponDamage = 10.0,
WeaponCooldownSeconds = 1.0
};
var report = AetheriaDaemonCombatKernel.Step(run, null, 0.2, catalog: null, settings);
var attacker = Entity(run, 1);
var target = Entity(run, 2);
var contact = attacker.Contacts.Single(contact => contact.TargetEntityIndex == 2);
RequireEqual(1, report.ShotCount, "actionable unresolved track should permit a committed shot");
Require(!contact.Visible, "track should remain unresolved below visibility threshold");
Require(Stat(target, "hull") < 100.0, "shot should apply native hull damage");
}
private static void KernelWritesNativeSnapshotRows()
{
var run = Scenario();
var report = AetheriaDaemonCombatKernel.Step(run, null, 0.5, catalog: null);
var attacker = Entity(run, 1);
Require(report.ResolvedContactCount > 0, "kernel should resolve contacts into native contact rows");
Require(attacker.Contacts.Count > 0, "contacts should live on AetheriaRuntimeEntitySnapshotCommit");
Require(attacker.WeaponStates.Any(state => state.OwnerKind == "daemon-combat-kernel"), "weapon state should be a native weapon state row");
Require(attacker.StatGrids.Any(grid => grid.Name == "cognitive-load"), "cognition pressure should be a native stat grid row");
Require(attacker.StatGrids.Any(grid => grid.Name == "heat-capacity"), "thermal capacity should be a native stat grid row");
}
private static AetheriaRuntimeRunCheckpointCommit Scenario(
double attackerContactConfidence = 0.7,
double targetShield = 15.0)
{
return new AetheriaRuntimeRunCheckpointCommit
{
RunId = "daemon-combat-smoke",
CurrentZoneIndex = 0,
CurrentEntityKey = "zone.0.entity.1",
Zones =
ZoneIndex = 0,
Entities = [source, target],
Projectiles =
[
new AetheriaRuntimeZoneSnapshotCommit
new AetheriaRuntimeProjectileCommit
{
ZoneIndex = 0,
SimulationTimeSeconds = 0,
Entities =
[
new AetheriaRuntimeEntitySnapshotCommit
{
EntityIndex = 1,
Name = "Cold hunter",
Kind = "ship",
FactionKey = "player",
PositionX = 0,
PositionZ = 0,
DirectionX = 1,
DirectionY = 0,
TargetEntityIndex = 2,
IsActive = true,
Contacts =
[
new AetheriaRuntimeEntityContactCommit
{
TargetEntityIndex = 2,
InfoGathered = attackerContactConfidence,
Hostile = true,
Visible = attackerContactConfidence >= 0.62
}
],
StatGrids =
[
Grid("hull", 100.0),
Grid("shield", 20.0),
Grid("heat", 4.0),
Grid("sensor-sensitivity", 1.3),
Grid("cognition", 1.4),
Grid("fire-control", 1.2),
Grid("signature-masking", 0.3)
]
},
new AetheriaRuntimeEntitySnapshotCommit
{
EntityIndex = 2,
Name = "Hot brute",
Kind = "ship",
FactionKey = "raider",
PositionX = 180,
PositionZ = 0,
DirectionX = -1,
DirectionY = 0,
TargetEntityIndex = 1,
IsActive = true,
StatGrids =
[
Grid("hull", 100.0),
Grid("shield", targetShield),
Grid("heat", 18.0),
Grid("heat-capacity", 180.0),
Grid("signature", 1.4),
Grid("signature-masking", 0.05),
Grid("sensor-sensitivity", 0.9),
Grid("cognition", 0.85),
Grid("fire-control", 1.05)
]
}
]
ProjectileId = "smoke-projectile",
SourceEntityIndex = 1,
TargetEntityIndex = 2,
PositionX = 0,
PositionZ = 0,
VelocityX = 100,
VelocityY = 0,
Radius = 1,
Damage = 12,
LifetimeSeconds = 5,
Active = true
}
]
};
}
private static AetheriaRuntimeEntitySnapshotCommit Entity(AetheriaRuntimeRunCheckpointCommit run, int index)
{
return run.Zones
.SelectMany(zone => zone.Entities)
.Single(entity => entity.EntityIndex == index);
}
private static AetheriaRuntimeEntityStatGridCommit Grid(string name, double value)
{
return new AetheriaRuntimeEntityStatGridCommit
var run = new AetheriaRuntimeRunCheckpointCommit
{
Name = name,
Width = 1,
Height = 1,
Values = [value]
RunId = "ymir-projectile-smoke",
CurrentZoneIndex = 0,
CurrentEntityKey = "zone.0.entity.1",
Zones = [zone]
};
return (run, zone, target);
}
private static double Stat(AetheriaRuntimeEntitySnapshotCommit entity, string name)
private static AetheriaRuntimeEntitySnapshotCommit Entity(int index, double x, string faction) => new()
{
return entity.StatGrids
.First(grid => string.Equals(grid.Name, name, StringComparison.OrdinalIgnoreCase))
.Values
.First();
}
EntityIndex = index,
Kind = "ship",
FactionKey = faction,
PositionX = x,
PositionZ = 0,
TargetEntityIndex = -1,
IsActive = true,
StatGrids = [Grid("hull", 100), Grid("shield", 0), Grid("heat", 0)]
};
private static string Snapshot(AetheriaRuntimeRunCheckpointCommit run)
private static AetheriaRuntimeEntityStatGridCommit Grid(string name, double value) => new()
{
return string.Join(
"|",
run.Zones
.SelectMany(zone => zone.Entities)
.OrderBy(entity => entity.EntityIndex)
.Select(entity =>
$"{entity.EntityIndex}:{Stat(entity, "hull"):0.000}:{Stat(entity, "shield"):0.000}:{Stat(entity, "heat"):0.000}:{Stat(entity, "cognitive-load"):0.000}:{entity.Contacts.Count}:{entity.WeaponStates.Count}"));
}
Name = name,
Width = 1,
Height = 1,
Values = [value]
};
private static void Require(bool condition, string message)
{
if (!condition)
throw new InvalidOperationException(message);
}
private static double Stat(AetheriaRuntimeEntitySnapshotCommit entity, string name) =>
entity.StatGrids.Single(grid => string.Equals(grid.Name, name, StringComparison.OrdinalIgnoreCase)).Values[0];
private static void RequireEqual<T>(T expected, T actual, string message)
{

View file

@ -9,5 +9,6 @@
<ItemGroup>
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
<ProjectReference Include="..\..\Ymir\src\Ymir.Core\Ymir.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,120 @@
using GameCult.Aetheria.State.Verse;
using Ymir.Core;
namespace Aetheria.State.Daemon;
public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePhysics
{
private const string ProjectileBodyPrefix = "aetheria.projectile.";
private const string EntityBodyPrefix = "aetheria.daemon.entity.";
private readonly YmirSimulator _simulator;
public AetheriaYmirProjectilePhysics(YmirSimulator? simulator = null)
{
_simulator = simulator ?? new YmirSimulator();
}
public string AuthorityId => "ymir.core";
public AetheriaRuntimeProjectileStep Step(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
ArgumentNullException.ThrowIfNull(zone);
if (deltaSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
var entityByBodyId = (entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.Where(entity => entity is { IsActive: true })
.ToDictionary(entity => EntityBodyId(entity.EntityIndex), StringComparer.Ordinal);
var survivors = new List<AetheriaRuntimeProjectileCommit>();
var hits = new List<AetheriaRuntimeProjectileHit>();
foreach (var projectile in zone.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>())
{
if (projectile is not { Active: true })
continue;
var projectileBodyId = ProjectileBodyId(projectile.ProjectileId);
var bodies = new List<PhysicsBody>
{
new(
projectileBodyId,
new Vec2((float)projectile.PositionX, (float)projectile.PositionZ),
new Vec2((float)projectile.VelocityX, (float)projectile.VelocityY),
(float)Math.Max(0.001, projectile.Radius),
1.0f,
Restitution: 0.0f)
};
foreach (var pair in entityByBodyId)
{
var entity = pair.Value;
if (entity.EntityIndex == projectile.SourceEntityIndex)
continue;
bodies.Add(new PhysicsBody(
pair.Key,
new Vec2((float)entity.PositionX, (float)entity.PositionZ),
Vec2.Zero,
ResolveEntityRadius(entity),
1.0f,
IsStatic: true,
Restitution: 0.0f));
}
var result = _simulator.Step(new SimulationStepRequest(
(float)deltaSeconds,
new YmirWorld((float)zone.SimulationTimeSeconds, bodies, Array.Empty<RadialField>())));
var projectileBody = result.World.Bodies.Single(body =>
string.Equals(body.Id, projectileBodyId, StringComparison.Ordinal));
projectile.PositionX = projectileBody.Position.X;
projectile.PositionZ = projectileBody.Position.Y;
projectile.VelocityX = projectileBody.Velocity.X;
projectile.VelocityY = projectileBody.Velocity.Y;
var contact = result.Contacts.FirstOrDefault(candidate =>
string.Equals(candidate.BodyA, projectileBodyId, StringComparison.Ordinal) ||
string.Equals(candidate.BodyB, projectileBodyId, StringComparison.Ordinal));
if (contact == null)
{
survivors.Add(projectile);
continue;
}
var targetBodyId = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal)
? contact.BodyB
: contact.BodyA;
if (!entityByBodyId.TryGetValue(targetBodyId, out var target))
{
survivors.Add(projectile);
continue;
}
var normal = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal)
? contact.Normal
: new Vec2(-contact.Normal.X, -contact.Normal.Y);
hits.Add(new AetheriaRuntimeProjectileHit
{
Projectile = projectile,
ProjectileBodyId = projectileBodyId,
TargetEntityIndex = target.EntityIndex,
TargetBodyId = targetBodyId,
PointX = contact.Point.X,
PointZ = contact.Point.Y,
NormalX = normal.X,
NormalZ = normal.Y
});
}
return new AetheriaRuntimeProjectileStep(survivors, hits);
}
private static string ProjectileBodyId(string? projectileId) =>
ProjectileBodyPrefix + (projectileId ?? "");
private static string EntityBodyId(int entityIndex) =>
EntityBodyPrefix + entityIndex;
private static float ResolveEntityRadius(AetheriaRuntimeEntitySnapshotCommit entity) =>
string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase) ? 48.0f : 20.0f;
}

View file

@ -1,5 +1,6 @@
using Aetheria.State;
using Aetheria.State.Documents;
using Aetheria.State.Daemon;
using GameCult.Aetheria.State.Verse;
using GameCult.Caching;
using GameCult.Eve.Surface;
@ -12,6 +13,7 @@ using System.Net.Sockets;
var options = AetheriaDaemonHostOptions.Parse(args);
var startedAtUtc = DateTimeOffset.UtcNow.ToString("O");
var projectilePhysics = new AetheriaYmirProjectilePhysics();
Console.WriteLine($"Aetheria Verse daemon starting: {options.StatePath}");
Console.WriteLine(options.EnableOdinAnnouncements
@ -42,7 +44,7 @@ using var cultMeshRudpHost = StartClientCultMeshHost(node, options, () => latest
using var clientPumpCancellation = new CancellationTokenSource();
var clientPump = RunClientCultMeshPumpAsync(cultMeshRudpHost, clientPumpCancellation.Token);
var nextApiPublicationUtc = DateTimeOffset.UtcNow;
var firstTick = await TickAsync(node, options, latestFrame, buildPublications: true).ConfigureAwait(false);
var firstTick = await TickAsync(node, options, projectilePhysics, latestFrame, buildPublications: true).ConfigureAwait(false);
ThrowIfClientPumpFaulted(clientPump);
latestFrame = firstTick.Frame;
nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval);
@ -79,7 +81,7 @@ while (!stopped.Task.IsCompleted)
}
var buildPublications = DateTimeOffset.UtcNow >= nextApiPublicationUtc;
var tick = await TickAsync(node, options, latestFrame, buildPublications).ConfigureAwait(false);
var tick = await TickAsync(node, options, projectilePhysics, latestFrame, buildPublications).ConfigureAwait(false);
ThrowIfClientPumpFaulted(clientPump);
latestFrame = tick.Frame;
nextTickUtc += options.TickInterval;
@ -114,6 +116,7 @@ static void ThrowIfClientPumpFaulted(Task clientPump)
static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
IAetheriaRuntimeProjectilePhysics projectilePhysics,
AetheriaRuntimeDaemonFrameDocument? currentFrame,
bool buildPublications)
{
@ -181,6 +184,7 @@ static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
Catalog = node.RuntimeCatalog().Latest(),
RenderSettings = options.RenderSettings,
SimulationSettings = options.SimulationSettings,
ProjectilePhysics = projectilePhysics,
StarbridgeScenario = starbridgeScenario,
StarbridgeSession = starbridgeSession,
BuildPublications = buildPublications,
@ -201,19 +205,6 @@ static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
$"velocity={entity.VelocityX},{entity.VelocityY}");
}
}
if (options.UseAbstractCombatKernel)
{
AetheriaDaemonCombatKernel.Step(
result.Frame.Run,
result.Intents,
fixedDeltaSeconds,
node.RuntimeCatalog().Latest(),
options.CombatKernelSettings);
result.Frame.Capabilities = (result.Frame.Capabilities ?? Array.Empty<string>())
.Concat(new[] { "aetheria.daemon.abstract_combat_kernel.v1" })
.Distinct(StringComparer.Ordinal)
.ToArray();
}
result.Frame.CumulativeImportedFactIds =
currentFrame?.CumulativeImportedFactIds ?? Array.Empty<string>();
result.Frame.CumulativeRejectedImportedFactIds =
@ -1907,7 +1898,11 @@ static EveAssetCatalogDocument BuildCoreAssetCatalog(
bundle.Uri,
bundle.Hash,
bundle.Size,
$"Assets/Resources/{resourcesPath}.prefab"))
$"Assets/Resources/{resourcesPath}.prefab",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["view.pilot.excludeUnityLayers"] = "14"
}))
.ToArray(),
new Dictionary<string, string>(StringComparer.Ordinal)
{
@ -2893,9 +2888,6 @@ internal sealed class AetheriaDaemonHostOptions
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public AetheriaRuntimeDaemonSimulationSettings SimulationSettings { get; init; } =
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault;
public AetheriaDaemonCombatKernelSettings CombatKernelSettings { get; init; } =
AetheriaDaemonCombatKernelSettings.Default;
public bool UseAbstractCombatKernel { get; init; }
public bool Once { get; init; }
public static AetheriaDaemonHostOptions Parse(IReadOnlyList<string> args)
@ -2956,7 +2948,6 @@ internal sealed class AetheriaDaemonHostOptions
TickInterval = TimeSpan.FromMilliseconds(intervalMs),
ApiPublicationInterval = TimeSpan.FromMilliseconds(apiPublicationIntervalMs),
FixedDeltaSeconds = fixedDeltaMs / 1000.0,
UseAbstractCombatKernel = HasFlag(args, "--abstract-combat-kernel"),
Once = HasFlag(args, "--once")
};
}

View file

@ -16446,12 +16446,14 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
"org.gamecult.aetheria.state",
"Runtime",
"AetheriaRuntimeDaemonSimulationSettings.cs");
var ymirProjectilePhysicsPath = Path.Combine(
var projectilePhysicsContractPath = Path.Combine(
root,
"Packages",
"org.gamecult.aetheria.state",
"Runtime",
"AetheriaRuntimeYmirProjectilePhysics.cs");
"AetheriaRuntimeProjectilePhysics.cs");
var ymirProjectilePhysicsPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaYmirProjectilePhysics.cs");
var daemonProjectPath = Path.Combine(root, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj");
var gameDocumentsPath = Path.Combine(
root,
"Packages",
@ -16504,9 +16506,13 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
var daemonSimulationSettings = File.Exists(daemonSimulationSettingsPath)
? File.ReadAllText(daemonSimulationSettingsPath)
: throw new InvalidOperationException("Cannot verify daemon simulation authority; daemon simulation settings are missing.");
var projectilePhysicsContract = File.Exists(projectilePhysicsContractPath)
? File.ReadAllText(projectilePhysicsContractPath)
: throw new InvalidOperationException("Cannot verify daemon projectile authority; shared physics port is missing.");
var ymirProjectilePhysics = File.Exists(ymirProjectilePhysicsPath)
? File.ReadAllText(ymirProjectilePhysicsPath)
: throw new InvalidOperationException("Cannot verify daemon projectile authority; Ymir projectile physics boundary is missing.");
: throw new InvalidOperationException("Cannot verify daemon projectile authority; daemon Ymir adapter is missing.");
var daemonProject = File.ReadAllText(daemonProjectPath);
var gameDocuments = File.Exists(gameDocumentsPath)
? File.ReadAllText(gameDocumentsPath)
: throw new InvalidOperationException("Cannot verify daemon projectile authority; game viewport documents are missing.");
@ -16574,10 +16580,10 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
{
"public static class AetheriaRuntimeDaemonSimulation",
"AetheriaRuntimeDaemonSimulationSettings settings",
"StepCombat(zone, entities, deltaSeconds, settings)",
"StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics)",
"EnsureDaemonWeaponState(",
"SpawnProjectile(zone, attacker, target, settings)",
"AetheriaRuntimeYmirProjectilePhysics.Step(zone, entities, deltaSeconds)",
"projectilePhysics.Step(zone, entities, deltaSeconds)",
"Damage(target, hit.Projectile.Damage)"
},
[daemonSimulationSettingsPath] = new[]
@ -16587,14 +16593,29 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
"public double ProjectileSpeed { get; }",
"public double AttackRange { get; }"
},
[projectilePhysicsContractPath] = new[]
{
"public interface IAetheriaRuntimeProjectilePhysics",
"public sealed class AetheriaRuntimeProjectilePhysicsUnavailable",
"public sealed class AetheriaRuntimeProjectileStep",
"public sealed class AetheriaRuntimeProjectileHit"
},
[ymirProjectilePhysicsPath] = new[]
{
"public static class AetheriaRuntimeYmirProjectilePhysics",
"public static AetheriaRuntimeYmirProjectileStep Step(",
"public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePhysics",
"private readonly YmirSimulator _simulator",
"ProjectileBodyPrefix",
"DaemonEntityBodyPrefix",
"TryResolveProjectileContact(",
"public sealed class AetheriaRuntimeYmirProjectileHit"
"EntityBodyPrefix",
"_simulator.Step(new SimulationStepRequest("
},
[daemonProjectPath] = new[]
{
"Ymir.Core.csproj"
},
[daemonProgramPath] = new[]
{
"var projectilePhysics = new AetheriaYmirProjectilePhysics();",
"ProjectilePhysics = projectilePhysics"
},
[gameDocumentsPath] = new[]
{
@ -16614,7 +16635,10 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
var text = pair.Key == snapshotDocumentsPath ? snapshotDocuments :
pair.Key == daemonSimulationPath ? daemonSimulation :
pair.Key == daemonSimulationSettingsPath ? daemonSimulationSettings :
pair.Key == projectilePhysicsContractPath ? projectilePhysicsContract :
pair.Key == ymirProjectilePhysicsPath ? ymirProjectilePhysics :
pair.Key == daemonProjectPath ? daemonProject :
pair.Key == daemonProgramPath ? daemonProgram :
pair.Key == gameDocumentsPath ? gameDocuments :
gameViewportDocuments;
return pair.Value

View file

@ -522,6 +522,7 @@ public class DaemonRuntimeDocumentTests
FrameId = 42,
SimulationTimeSeconds = 12.5,
FixedDeltaSeconds = 0.02,
ProjectilePhysics = new PassthroughProjectilePhysics(),
ObservedCommands = new[] { targetCommand, movementCommand }
});
@ -942,6 +943,7 @@ public class DaemonRuntimeDocumentTests
FrameId = 43,
SimulationTimeSeconds = 12.52,
FixedDeltaSeconds = 0.02,
ProjectilePhysics = new PassthroughProjectilePhysics(),
ObservedCommands = new[] { targetCommand },
AccountedCommandIds = new[] { targetCommand.CommandId }
});
@ -2030,7 +2032,8 @@ public class DaemonRuntimeDocumentTests
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault);
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
new PassthroughProjectilePhysics());
var zone = FindZone(run, 0);
Assert.IsNotEmpty(zone.Projectiles);
@ -2131,7 +2134,8 @@ public class DaemonRuntimeDocumentTests
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
settings);
settings,
new PassthroughProjectilePhysics());
var zone = FindZone(run, 0);
var projectile = zone.Projectiles.Single(projectile => projectile.SourceEntityIndex == 0);
@ -2211,7 +2215,8 @@ public class DaemonRuntimeDocumentTests
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault);
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
new PassthroughProjectilePhysics());
Assert.IsTrue(pilot.CargoContents.SelectMany(bay => bay.Items).Any(slot => slot.Item.ItemKey == "raider-salvage"));
Assert.IsEmpty(wreck.CargoContents);
@ -3021,7 +3026,8 @@ public class DaemonRuntimeDocumentTests
CultMeshAddress = "cultmesh://aetheria.local/eve/providers/aetheria.daemon",
FrameId = frame.FrameId,
SimulationTimeSeconds = frame.SimulationTimeSeconds,
FixedDeltaSeconds = frame.FixedDeltaSeconds
FixedDeltaSeconds = frame.FixedDeltaSeconds,
ProjectilePhysics = new PassthroughProjectilePhysics()
});
PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame);
PublishDaemonSurfacesThroughVerseClient(statePath, tickResult);
@ -3176,7 +3182,8 @@ public class DaemonRuntimeDocumentTests
CultMeshAddress = "cultmesh://aetheria.local/eve/providers/aetheria.daemon",
FrameId = frame.FrameId,
SimulationTimeSeconds = frame.SimulationTimeSeconds,
FixedDeltaSeconds = frame.FixedDeltaSeconds
FixedDeltaSeconds = frame.FixedDeltaSeconds,
ProjectilePhysics = new PassthroughProjectilePhysics()
});
PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame);
PublishDaemonSurfacesThroughVerseClient(statePath, tickResult);
@ -5553,4 +5560,19 @@ public class DaemonRuntimeDocumentTests
{
return document.LatestAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
private sealed class PassthroughProjectilePhysics : IAetheriaRuntimeProjectilePhysics
{
public string AuthorityId => "test.passthrough";
public AetheriaRuntimeProjectileStep Step(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
return new AetheriaRuntimeProjectileStep(
zone.Projectiles,
Array.Empty<AetheriaRuntimeProjectileHit>());
}
}
}

View file

@ -16,10 +16,13 @@ namespace GameCult.Aetheria.State.Verse
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeDaemonIntentState intents,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings)
AetheriaRuntimeDaemonSimulationSettings settings,
IAetheriaRuntimeProjectilePhysics projectilePhysics)
{
if (run == null || deltaSeconds <= 0)
return;
if (projectilePhysics == null)
throw new ArgumentNullException(nameof(projectilePhysics));
foreach (var zone in run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
{
@ -37,7 +40,7 @@ namespace GameCult.Aetheria.State.Verse
StepRaiderAi(entities);
StepTargetPursuit(entities, settings);
StepMovement(entities, deltaSeconds);
StepCombat(run, zone, entities, intents, deltaSeconds, settings);
StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics);
StepTractorSalvage(run, zone, entities, settings);
RefreshContacts(entities, settings);
}
@ -175,7 +178,8 @@ namespace GameCult.Aetheria.State.Verse
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonIntentState intents,
double deltaSeconds,
AetheriaRuntimeDaemonSimulationSettings settings)
AetheriaRuntimeDaemonSimulationSettings settings,
IAetheriaRuntimeProjectilePhysics projectilePhysics)
{
var byIndex = entities.ToDictionary(entity => entity.EntityIndex);
foreach (var attacker in entities)
@ -214,7 +218,12 @@ namespace GameCult.Aetheria.State.Verse
SetStat(attacker, Heat, Math.Min(100, GetStat(attacker, Heat) + ResolveProjectileDamage(attacker, settings) * settings.ProjectileHeatScale));
}
var projectileStep = AetheriaRuntimeYmirProjectilePhysics.Step(zone, entities, deltaSeconds);
PrepareProjectiles(zone, byIndex, deltaSeconds);
var projectileStep = zone.Projectiles.Count == 0
? new AetheriaRuntimeProjectileStep(
Array.Empty<AetheriaRuntimeProjectileCommit>(),
Array.Empty<AetheriaRuntimeProjectileHit>())
: projectilePhysics.Step(zone, entities, deltaSeconds);
zone.Projectiles = projectileStep.Projectiles;
foreach (var hit in projectileStep.Hits)
{
@ -235,6 +244,56 @@ namespace GameCult.Aetheria.State.Verse
}
}
private static void PrepareProjectiles(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyDictionary<int, AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
var active = new List<AetheriaRuntimeProjectileCommit>();
foreach (var projectile in zone.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>())
{
if (projectile == null || !projectile.Active)
continue;
projectile.AgeSeconds += deltaSeconds;
if (projectile.AgeSeconds >= projectile.LifetimeSeconds)
continue;
if (projectile.Guided &&
projectile.TargetEntityIndex >= 0 &&
entities.TryGetValue(projectile.TargetEntityIndex, out var target) &&
IsAlive(target))
{
GuideProjectile(projectile, target);
}
active.Add(projectile);
}
zone.Projectiles = active;
}
private static void GuideProjectile(
AetheriaRuntimeProjectileCommit 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 bool WantsFire(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,

View file

@ -34,6 +34,8 @@ namespace GameCult.Aetheria.State.Verse
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public AetheriaRuntimeDaemonSimulationSettings SimulationSettings { get; set; } =
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault;
public IAetheriaRuntimeProjectilePhysics ProjectilePhysics { get; set; } =
AetheriaRuntimeProjectilePhysicsUnavailable.Instance;
public bool BuildPublications { get; set; } = true;
}
@ -135,7 +137,8 @@ namespace GameCult.Aetheria.State.Verse
operationResult.Run,
operationResult.Intents,
options.FixedDeltaSeconds,
options.SimulationSettings);
options.SimulationSettings,
options.ProjectilePhysics);
StampZoneSimulationTime(operationResult.Run, options.SimulationTimeSeconds);
var frame = AetheriaRuntimeDaemonFrameDocument.Create(

View file

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public interface IAetheriaRuntimeProjectilePhysics
{
string AuthorityId { get; }
AetheriaRuntimeProjectileStep Step(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds);
}
public sealed class AetheriaRuntimeProjectilePhysicsUnavailable : IAetheriaRuntimeProjectilePhysics
{
public static AetheriaRuntimeProjectilePhysicsUnavailable Instance { get; } =
new AetheriaRuntimeProjectilePhysicsUnavailable();
private AetheriaRuntimeProjectilePhysicsUnavailable()
{
}
public string AuthorityId => "unavailable";
public AetheriaRuntimeProjectileStep Step(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
throw new InvalidOperationException(
"Aetheria projectile simulation requires an injected authoritative physics owner.");
}
}
public sealed class AetheriaRuntimeProjectileStep
{
public AetheriaRuntimeProjectileStep(
IReadOnlyList<AetheriaRuntimeProjectileCommit> projectiles,
IReadOnlyList<AetheriaRuntimeProjectileHit> hits)
{
Projectiles = projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>();
Hits = hits ?? Array.Empty<AetheriaRuntimeProjectileHit>();
}
public IReadOnlyList<AetheriaRuntimeProjectileCommit> Projectiles { get; }
public IReadOnlyList<AetheriaRuntimeProjectileHit> Hits { get; }
}
public sealed class AetheriaRuntimeProjectileHit
{
public AetheriaRuntimeProjectileCommit Projectile { get; set; } =
new AetheriaRuntimeProjectileCommit();
public string ProjectileBodyId { get; set; } = "";
public int TargetEntityIndex { get; set; }
public string TargetBodyId { get; set; } = "";
public double PointX { get; set; }
public double PointZ { get; set; }
public double NormalX { get; set; }
public double NormalZ { get; set; }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5437add74c5d2fb45b81299b41aa999d

View file

@ -1,207 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public static class AetheriaRuntimeYmirProjectilePhysics
{
private const string ProjectileBodyPrefix = "aetheria.projectile.";
private const string DaemonEntityBodyPrefix = "aetheria.daemon.entity.";
private const double DefaultEntityPhysicsRadius = 20.0;
public static AetheriaRuntimeYmirProjectileStep Step(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
if (zone == null || deltaSeconds <= 0)
{
return new AetheriaRuntimeYmirProjectileStep(
zone?.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>(),
Array.Empty<AetheriaRuntimeYmirProjectileHit>());
}
var entityByIndex = (entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.Where(entity => entity != null)
.ToDictionary(entity => entity.EntityIndex);
var activeProjectiles = new List<AetheriaRuntimeProjectileCommit>();
var hits = new List<AetheriaRuntimeYmirProjectileHit>();
foreach (var projectile in zone.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>())
{
if (projectile == null || !projectile.Active)
continue;
projectile.AgeSeconds += deltaSeconds;
if (projectile.AgeSeconds >= projectile.LifetimeSeconds)
continue;
AetheriaRuntimeEntitySnapshotCommit? target = null;
if (projectile.TargetEntityIndex >= 0 &&
entityByIndex.TryGetValue(projectile.TargetEntityIndex, out var resolvedTarget) &&
IsActiveBody(resolvedTarget))
{
target = resolvedTarget;
}
if (projectile.Guided && target != null)
GuideProjectile(projectile, target);
var previousX = projectile.PositionX;
var previousZ = projectile.PositionZ;
projectile.PositionX += projectile.VelocityX * deltaSeconds;
projectile.PositionZ += projectile.VelocityY * deltaSeconds;
if (target != null &&
TryResolveProjectileContact(projectile, previousX, previousZ, target, out var contact))
{
hits.Add(contact);
continue;
}
activeProjectiles.Add(projectile);
}
return new AetheriaRuntimeYmirProjectileStep(
activeProjectiles.ToArray(),
hits.ToArray());
}
private static void GuideProjectile(
AetheriaRuntimeProjectileCommit projectile,
AetheriaRuntimeEntitySnapshotCommit target)
{
var speed = Math.Sqrt(projectile.VelocityX * projectile.VelocityX + projectile.VelocityY * projectile.VelocityY);
if (speed <= 0.0001)
return;
var direction = Normalize(target.PositionX - projectile.PositionX, target.PositionZ - projectile.PositionZ);
if (Math.Abs(direction.X) + Math.Abs(direction.Y) <= 0.0001)
return;
projectile.DirectionX = direction.X;
projectile.DirectionY = direction.Y;
projectile.VelocityX = direction.X * speed;
projectile.VelocityY = direction.Y * speed;
}
private static bool TryResolveProjectileContact(
AetheriaRuntimeProjectileCommit projectile,
double previousX,
double previousZ,
AetheriaRuntimeEntitySnapshotCommit target,
out AetheriaRuntimeYmirProjectileHit contact)
{
var targetRadius = ResolveEntityPhysicsRadius(target);
var combinedRadius = Math.Max(0.001, projectile.Radius) + targetRadius;
var currentDistanceSq = Sqr(target.PositionX - projectile.PositionX) + Sqr(target.PositionZ - projectile.PositionZ);
if (currentDistanceSq <= combinedRadius * combinedRadius)
{
contact = Contact(projectile, target, projectile.PositionX, projectile.PositionZ);
return true;
}
var segmentX = projectile.PositionX - previousX;
var segmentZ = projectile.PositionZ - previousZ;
var segmentLengthSq = segmentX * segmentX + segmentZ * segmentZ;
if (segmentLengthSq <= 0.000001)
{
contact = default!;
return false;
}
var targetX = target.PositionX - previousX;
var targetZ = target.PositionZ - previousZ;
var t = Math.Max(0, Math.Min(1, (targetX * segmentX + targetZ * segmentZ) / segmentLengthSq));
var closestX = previousX + segmentX * t;
var closestZ = previousZ + segmentZ * t;
var closestDistanceSq = Sqr(target.PositionX - closestX) + Sqr(target.PositionZ - closestZ);
if (closestDistanceSq > combinedRadius * combinedRadius)
{
contact = default!;
return false;
}
contact = Contact(projectile, target, closestX, closestZ);
return true;
}
private static AetheriaRuntimeYmirProjectileHit Contact(
AetheriaRuntimeProjectileCommit projectile,
AetheriaRuntimeEntitySnapshotCommit target,
double pointX,
double pointZ)
{
var normal = Normalize(pointX - target.PositionX, pointZ - target.PositionZ);
return new AetheriaRuntimeYmirProjectileHit
{
Projectile = projectile,
ProjectileBodyId = ProjectileBodyPrefix + (projectile.ProjectileId ?? ""),
TargetEntityIndex = target.EntityIndex,
TargetBodyId = DaemonEntityBodyPrefix + target.EntityIndex,
PointX = pointX,
PointZ = pointZ,
NormalX = normal.X,
NormalZ = normal.Y
};
}
private static double ResolveEntityPhysicsRadius(AetheriaRuntimeEntitySnapshotCommit entity)
{
if (string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase))
return 48.0;
return DefaultEntityPhysicsRadius;
}
private static bool IsActiveBody(AetheriaRuntimeEntitySnapshotCommit entity)
{
return entity.IsActive;
}
private static double Sqr(double value) => value * value;
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);
}
}
public sealed class AetheriaRuntimeYmirProjectileStep
{
public AetheriaRuntimeYmirProjectileStep(
IReadOnlyList<AetheriaRuntimeProjectileCommit> projectiles,
IReadOnlyList<AetheriaRuntimeYmirProjectileHit> hits)
{
Projectiles = projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>();
Hits = hits ?? Array.Empty<AetheriaRuntimeYmirProjectileHit>();
}
public IReadOnlyList<AetheriaRuntimeProjectileCommit> Projectiles { get; }
public IReadOnlyList<AetheriaRuntimeYmirProjectileHit> Hits { get; }
}
public sealed class AetheriaRuntimeYmirProjectileHit
{
public AetheriaRuntimeProjectileCommit Projectile { get; set; } = new AetheriaRuntimeProjectileCommit();
public string ProjectileBodyId { get; set; } = "";
public int TargetEntityIndex { get; set; }
public string TargetBodyId { get; set; } = "";
public double PointX { get; set; }
public double PointZ { get; set; }
public double NormalX { get; set; }
public double NormalZ { get; set; }
}
}