From fe0c193724084d6ef1422b179e3e69ffa04c46eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 17:43:48 +0100 Subject: [PATCH] Move projectile physics behind daemon Ymir authority --- .../Aetheria.State.Daemon.Smoke.csproj | 1 + Aetheria.State.Daemon.Smoke/Program.cs | 251 +++++++----------- .../Aetheria.State.Daemon.csproj | 1 + .../AetheriaYmirProjectilePhysics.cs | 120 +++++++++ Aetheria.State.Daemon/Program.cs | 31 +-- Aetheria.State.Verify/Program.cs | 44 ++- .../Tests/DaemonRuntimeDocumentTests.cs | 32 ++- .../AetheriaRuntimeDaemonSimulation.cs | 67 ++++- .../AetheriaRuntimeDaemonTickRunner.cs | 5 +- .../AetheriaRuntimeProjectilePhysics.cs | 73 +++++ .../AetheriaRuntimeProjectilePhysics.cs.meta | 2 + .../AetheriaRuntimeYmirProjectilePhysics.cs | 207 --------------- 12 files changed, 427 insertions(+), 407 deletions(-) create mode 100644 Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta delete mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs diff --git a/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj b/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj index 8c34ca0a4..1dfbf7d59 100644 --- a/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj +++ b/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj @@ -9,5 +9,6 @@ + diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 572a4c585..0679cdc60 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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 expected, T actual, string message) { diff --git a/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj b/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj index ca788873d..b7350c9be 100644 --- a/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj +++ b/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj @@ -9,5 +9,6 @@ + diff --git a/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs b/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs new file mode 100644 index 000000000..969612fbc --- /dev/null +++ b/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs @@ -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 entities, + double deltaSeconds) + { + ArgumentNullException.ThrowIfNull(zone); + if (deltaSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(deltaSeconds)); + + var entityByBodyId = (entities ?? Array.Empty()) + .Where(entity => entity is { IsActive: true }) + .ToDictionary(entity => EntityBodyId(entity.EntityIndex), StringComparer.Ordinal); + var survivors = new List(); + var hits = new List(); + + foreach (var projectile in zone.Projectiles ?? Array.Empty()) + { + if (projectile is not { Active: true }) + continue; + + var projectileBodyId = ProjectileBodyId(projectile.ProjectileId); + var bodies = new List + { + 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()))); + 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; +} diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 4552224e6..07dc47c54 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -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 TickAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, + IAetheriaRuntimeProjectilePhysics projectilePhysics, AetheriaRuntimeDaemonFrameDocument? currentFrame, bool buildPublications) { @@ -181,6 +184,7 @@ static async Task 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 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()) - .Concat(new[] { "aetheria.daemon.abstract_combat_kernel.v1" }) - .Distinct(StringComparer.Ordinal) - .ToArray(); - } result.Frame.CumulativeImportedFactIds = currentFrame?.CumulativeImportedFactIds ?? Array.Empty(); 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(StringComparer.Ordinal) + { + ["view.pilot.excludeUnityLayers"] = "14" + })) .ToArray(), new Dictionary(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 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") }; } diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 8fba59222..52678cf8a 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -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 diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index e18b1d391..0a642066b 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -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 entities, + double deltaSeconds) + { + return new AetheriaRuntimeProjectileStep( + zone.Projectiles, + Array.Empty()); + } + } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index adf0607e5..eb229b63d 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -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()) { @@ -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 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(), + Array.Empty()) + : 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 entities, + double deltaSeconds) + { + var active = new List(); + foreach (var projectile in zone.Projectiles ?? Array.Empty()) + { + 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, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs index d1c50eb29..66d24d04c 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs @@ -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( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs new file mode 100644 index 000000000..c0443faea --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs @@ -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 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 entities, + double deltaSeconds) + { + throw new InvalidOperationException( + "Aetheria projectile simulation requires an injected authoritative physics owner."); + } + } + + public sealed class AetheriaRuntimeProjectileStep + { + public AetheriaRuntimeProjectileStep( + IReadOnlyList projectiles, + IReadOnlyList hits) + { + Projectiles = projectiles ?? Array.Empty(); + Hits = hits ?? Array.Empty(); + } + + public IReadOnlyList Projectiles { get; } + + public IReadOnlyList 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; } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta new file mode 100644 index 000000000..f756a4e89 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5437add74c5d2fb45b81299b41aa999d diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs deleted file mode 100644 index 27e4fc336..000000000 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs +++ /dev/null @@ -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 entities, - double deltaSeconds) - { - if (zone == null || deltaSeconds <= 0) - { - return new AetheriaRuntimeYmirProjectileStep( - zone?.Projectiles ?? Array.Empty(), - Array.Empty()); - } - - var entityByIndex = (entities ?? Array.Empty()) - .Where(entity => entity != null) - .ToDictionary(entity => entity.EntityIndex); - var activeProjectiles = new List(); - var hits = new List(); - - foreach (var projectile in zone.Projectiles ?? Array.Empty()) - { - 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 projectiles, - IReadOnlyList hits) - { - Projectiles = projectiles ?? Array.Empty(); - Hits = hits ?? Array.Empty(); - } - - public IReadOnlyList Projectiles { get; } - - public IReadOnlyList 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; } - } -}