diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 1055ac23c..6625e8f9a 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -18,6 +18,11 @@ else if (args.Contains("--pickup", StringComparer.Ordinal)) checks.RunPickup(); Console.WriteLine("Daemon Ymir pickup-contact smoke passed."); } +else if (args.Contains("--payload", StringComparer.Ordinal)) +{ + checks.RunPayload(); + Console.WriteLine("Daemon retained Ymir payload smoke passed."); +} else { checks.Run(); @@ -38,6 +43,14 @@ internal sealed class AetheriaDaemonYmirSmokeChecks PickupShieldContactCollectsOrBounces(); } + public void RunPayload() + { + YmirMovesProjectileAndReportsStableContact(); + PayloadQueryExcludesItsSource(); + PayloadBodiesDoNotCollideWithEachOther(); + OverlappingMineRemainsQueryableAfterArming(); + } + private static void RetainedWorldAdvancesEveryFixedSubstep() { var ship = Entity(0, 0, "player"); @@ -60,7 +73,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationStepCount = 3, SimulationTimeSeconds = 0.3, - PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); RequireNear(3, ship.PositionX, 0.05, @@ -96,7 +108,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, - PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); @@ -116,7 +127,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, - PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); @@ -142,7 +152,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks DestructionDropsLootExactlyOnce(); DaemonSimulationTreatsYmirHitAsPresentationOnly(); ProjectileContactCannotKill(); - MissingPhysicsOwnerCannotAdvanceProjectiles(); MissingWorldPhysicsOwnerCannotAdvanceShips(); TractorRampsAndPullsThroughYmirWithoutTeleportingCargo(); PickupIsCapacityCheckedExactlyOnceAndExpires(); @@ -276,7 +285,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks }); for (var frame = 0; frame < 7; frame++) AetheriaRuntimeDaemonSimulation.Step(run, intents, 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); var state = source.WeaponStates.Single(value => value.BehaviorKind == AetheriaRuntimeBehaviorKinds.ConstantWeapon); @@ -298,10 +307,10 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "constant weapon must pay elapsed-time energy through canonical capacitor state"); for (var frame = 7; frame < 11; frame++) AetheriaRuntimeDaemonSimulation.Step(run, intents, 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), catalog, 11, 1.1); Require(!state.Firing && run.GameEvents.Count(value => value.Kind == "weapon.firing.stopped") == 2, "releasing held fire must stop the persisted constant weapon and publish its transition"); @@ -355,7 +364,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks ActorEntityKey = "zone.0.entity.0", WeaponGroup = 0, Fire = true, Active = true }); AetheriaRuntimeDaemonSimulation.Step(run, intents, 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-charged", payload)], [], []), 0, 0); @@ -366,7 +375,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var state = source.WeaponStates.Single(value => value.BehaviorKind == AetheriaRuntimeBehaviorKinds.ChargedWeapon); for (var frame = 1; frame < 11; frame++) AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-charged", payload)], [], []), frame, frame * 0.1); Require(state.Charging && state.Charged && state.Charge >= 1 && state.ChargeHoldSeconds > 0, @@ -375,7 +384,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "ready charged weapon must hold rather than invent a firing solution"); source.TargetEntityIndex = target.EntityIndex; AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-charged", payload)], [], []), 11, 1.1); Require(run.GameEvents.Count(value => value.Kind == "weapon.charge.committed") == 1 && @@ -447,11 +456,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks }); AetheriaRuntimeDaemonSimulation.Step(run, fire, 0.1, - AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirPhysicalPayloadPhysics(), + AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirWorldPhysics(), catalog, 0, 0); for (var frame = 1; frame < 12 && run.ShotReceipts.Count == 0; frame++) AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, - AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirPhysicalPayloadPhysics(), + AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); Require(run.ShotReceipts.Count == 1 && run.ShotReceipts[0].SourceEntityIndex == source.EntityIndex, @@ -500,7 +509,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeDaemonSimulation.Step(run, fire, 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, 1, 0.1); + new AetheriaYmirWorldPhysics(), catalog, 1, 0.1); RequireEqual(1, zone.PhysicalPayloads.Count, "deployable fire must create one persistent physical payload"); Require(!run.ShotReceipts.Any(), "deployable fire must not masquerade as an ordinary shot receipt"); var mine = zone.PhysicalPayloads.Single(); @@ -509,7 +518,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, 2, 0.2); + new AetheriaYmirWorldPhysics(), catalog, 2, 0.2); mine = zone.PhysicalPayloads.Single(); Require(mine.Stationary && mine.TriggeredAtSeconds >= 0 && run.GameEvents.Count(value => value.Kind == "deployable.triggered") == 1, @@ -536,7 +545,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.21, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, 3, 0.41); + new AetheriaYmirWorldPhysics(), catalog, 3, 0.41); RequireEqual(0, zone.PhysicalPayloads.Count, "detonated mine must leave canonical payload state"); RequireNear(65, Stat(target, "hull"), 0.000001, "Aetheria must apply authored blast damage after the delay"); @@ -578,7 +587,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), frameId: 4, + new AetheriaYmirWorldPhysics(), frameId: 4, simulationTimeSeconds: 0.1); RequireEqual(0, zone.PhysicalPayloads.Count, @@ -660,7 +669,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AddDetonatingPayload("shielded-blast", 20); AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, 1, 0.1); + new AetheriaYmirWorldPhysics(), catalog, 1, 0.1); var capacitor = target.BehaviorStates.Single(value => value.BehaviorKind == "Capacitor"); RequireNear(100, Stat(target, "hull"), 0.000001, "funded shield must intercept damage before hull"); @@ -689,7 +698,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AddDetonatingPayload("unfunded-blast", 20); AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, 2, 0.2); + new AetheriaYmirWorldPhysics(), catalog, 2, 0.2); RequireNear(80, Stat(target, "hull"), 0.000001, "unfunded shield must not manufacture absorption"); } @@ -724,7 +733,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, 1, 0.1); + new AetheriaYmirWorldPhysics(), catalog, 1, 0.1); var armor = target.StatGrids.Single(value => value.Name == "armor"); RequireNear(0, armor.Values[3], 0.000001, "source-facing splash cells must absorb damage first"); @@ -775,7 +784,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var settings = LootSettings(1); AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), null, 1, 0.1); + new AetheriaYmirWorldPhysics(), null, 1, 0.1); Require(!target.IsActive && target.DestroyedFrameId == 1 && !string.IsNullOrWhiteSpace(target.DestructionId), @@ -810,7 +819,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "provider must advertise the original destruction effect by semantic feedback role"); AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), null, 2, 0.2); + new AetheriaYmirWorldPhysics(), null, 2, 0.2); RequireEqual(3, zone.DroppedPickups.Count, "later ticks must not duplicate an already committed destruction transaction"); RequireEqual(1, run.GameEvents.Count(value => value.Kind == "entity.destroyed"), @@ -847,7 +856,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks if (frame == 0) intents.WeaponGroups.Add(new AetheriaRuntimeDaemonWeaponGroupIntent { ActorEntityKey = "zone.0.entity.0", WeaponGroup = 0, Fire = true, Active = true }); AetheriaRuntimeDaemonSimulation.Step(run, intents, 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); } var state = source.WeaponStates.Single(value => value.BehaviorKind == AetheriaRuntimeBehaviorKinds.ChargedWeapon); @@ -1325,7 +1334,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), - PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); Require(tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":move", StringComparison.Ordinal)), @@ -1435,7 +1443,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), - PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); sawPickup |= tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":pickup", StringComparison.Ordinal)); @@ -1570,7 +1577,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), Catalog = catalog, - PhysicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics(), BuildPublications = false }); sawTargetCommand |= tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":target", StringComparison.Ordinal)); @@ -2043,7 +2049,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), - PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); if (frame == 0) @@ -2115,7 +2120,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks operation.Intents, 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, new AetheriaYmirWorldPhysics()); Require(player.VelocityX > 0 && Math.Abs(player.VelocityY) < 0.001, "player command must drive its actor through the shared movement lever"); @@ -2450,7 +2454,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, frameId: 17); + new AetheriaYmirWorldPhysics(), catalog, frameId: 17); RequireEqual("heatstroke", entity.CauseOfDeath, "lethal cockpit heat must persist the typed cause of death"); @@ -2509,14 +2513,131 @@ internal sealed class AetheriaDaemonYmirSmokeChecks private static void YmirMovesProjectileAndReportsStableContact() { var (run, zone, target) = Scenario(); - var step = new AetheriaYmirPhysicalPayloadPhysics().Step(zone, zone.Entities, 0.1); + using var physics = new AetheriaYmirWorldPhysics(); + physics.RetainWorlds(run.RunId, [zone.ZoneIndex]); + physics.Step(run.RunId, 0, 0, zone, zone.Entities, 0.1); + var step = physics.StepPhysicalPayloads(run.RunId, 0, 0, zone, zone.Entities, 0.1); - RequireEqual("ymir.core", new AetheriaYmirPhysicalPayloadPhysics().ImplementationId, "adapter must identify its spatial implementation"); + RequireEqual("ymir.box3d.retained-session.v1", physics.ImplementationId, "zone physics must identify its retained Ymir implementation"); RequireEqual(0, step.PhysicalPayloads.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.physical-payload.smoke-projectile", step.Hits[0].PhysicalPayloadBodyId, "physical payload body id must be stable"); - RequireEqual("aetheria.daemon.entity.2", step.Hits[0].TargetBodyId, "entity body id must be stable"); + RequireEqual("aetheria.daemon.entity.smoke.entity.2", step.Hits[0].TargetBodyId, "entity body id must be stable"); + } + + private static void PayloadQueryExcludesItsSource() + { + var source = Entity(1, 0, "player"); + var zone = new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Entities = [source], + PhysicalPayloads = + [ + new AetheriaRuntimePhysicalPayloadCommit + { + PayloadId = "source-exclusion", + SourceEntityIndex = source.EntityIndex, + PositionX = 0, + PositionZ = 0, + VelocityX = 10, + Radius = 1, + LifetimeSeconds = 5, + Active = true + } + ] + }; + using var physics = new AetheriaYmirWorldPhysics(); + physics.RetainWorlds("source-exclusion-smoke", [zone.ZoneIndex]); + physics.Step("source-exclusion-smoke", 1, 0, zone, zone.Entities, 0.1); + var step = physics.StepPhysicalPayloads("source-exclusion-smoke", 1, 0, zone, zone.Entities, 0.1); + + RequireEqual(0, step.Hits.Count, "a payload query must not hit its source body"); + RequireEqual(1, step.PhysicalPayloads.Count, "source exclusion must leave the payload alive"); + } + + private static void PayloadBodiesDoNotCollideWithEachOther() + { + var zone = new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Entities = [], + PhysicalPayloads = + [ + new AetheriaRuntimePhysicalPayloadCommit + { + PayloadId = "payload-left", + SourceEntityIndex = -1, + PositionX = -5, + VelocityX = 100, + Radius = 1, + LifetimeSeconds = 5, + Active = true + }, + new AetheriaRuntimePhysicalPayloadCommit + { + PayloadId = "payload-right", + SourceEntityIndex = -1, + PositionX = 5, + VelocityX = -100, + Radius = 1, + LifetimeSeconds = 5, + Active = true + } + ] + }; + using var physics = new AetheriaYmirWorldPhysics(); + physics.RetainWorlds("payload-isolation-smoke", [zone.ZoneIndex]); + physics.Step("payload-isolation-smoke", 2, 0, zone, zone.Entities, 0.1); + var step = physics.StepPhysicalPayloads("payload-isolation-smoke", 2, 0, zone, zone.Entities, 0.1); + + RequireEqual(0, step.Hits.Count, "payload bodies must not manufacture world-query hits against each other"); + RequireEqual(2, step.PhysicalPayloads.Count, "payload bodies must survive crossing each other"); + Require(step.PhysicalPayloads.Single(payload => payload.PayloadId == "payload-left").VelocityX > 0, + "payload-to-payload response must not reverse the left payload"); + Require(step.PhysicalPayloads.Single(payload => payload.PayloadId == "payload-right").VelocityX < 0, + "payload-to-payload response must not reverse the right payload"); + } + + private static void OverlappingMineRemainsQueryableAfterArming() + { + var target = Entity(2, 0, "enemy"); + var mine = new AetheriaRuntimePhysicalPayloadCommit + { + PayloadId = "arming-overlap", + PayloadKind = "mine", + SourceEntityIndex = 1, + PositionX = 0, + PositionZ = 0, + Radius = 1, + TriggerRadius = 25, + ActivationDelaySeconds = 1, + LifetimeSeconds = 10, + Stationary = false, + Active = true + }; + var zone = new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Entities = [target], + PhysicalPayloads = [mine] + }; + using var physics = new AetheriaYmirWorldPhysics(); + physics.RetainWorlds("mine-arming-overlap-smoke", [zone.ZoneIndex]); + physics.Step("mine-arming-overlap-smoke", 1, 0, zone, zone.Entities, 0.5); + var unarmed = physics.StepPhysicalPayloads("mine-arming-overlap-smoke", 1, 0, zone, zone.Entities, 0.5); + RequireEqual(1, unarmed.Hits.Count, "an unarmed overlap may be observed without granting Ymir trigger authority"); + + mine.AgeSeconds = mine.ActivationDelaySeconds; + mine.Radius = mine.TriggerRadius; + zone.PhysicalPayloads = unarmed.PhysicalPayloads; + physics.Step("mine-arming-overlap-smoke", 2, 0, zone, zone.Entities, 0.5); + var armed = physics.StepPhysicalPayloads("mine-arming-overlap-smoke", 2, 0, zone, zone.Entities, 0.5); + RequireEqual(1, armed.Hits.Count, + "a retained mine already overlapping at activation must still yield an eligible Box3D overlap fact"); + RequireEqual(target.EntityIndex, armed.Hits[0].TargetEntityIndex, + "the armed overlap fact must preserve the target identity"); } private static void DaemonSimulationTreatsYmirHitAsPresentationOnly() @@ -2528,7 +2649,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics()); RequireEqual(100.0, Stat(target, "hull"), "presentation projectile contact must not write combat damage"); @@ -2550,34 +2670,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var (run, _, target) = Scenario(); target.StatGrids.Single(grid => grid.Name == "hull").Values = [5]; AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, - AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), frameId: 9); + AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirWorldPhysics(), frameId: 9); Require(target.IsActive && Math.Abs(Stat(target, "hull") - 5) < 0.000001, "even nominally lethal presentation contact must leave canonical target state untouched"); RequireEqual(0, run.GameEvents.Count(value => value.Kind == "entity.destroyed" && value.TargetEntityIndex == target.EntityIndex), "presentation contact must not manufacture a destruction event"); } - private static void MissingPhysicsOwnerCannotAdvanceProjectiles() - { - var (run, _, _) = Scenario(); - try - { - AetheriaRuntimeDaemonSimulation.Step( - run, - new AetheriaRuntimeDaemonIntentState(), - 0.1, - AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, - new AetheriaYmirWorldPhysics()); - } - catch (InvalidOperationException) - { - return; - } - - throw new InvalidOperationException("daemon advanced a projectile without an authoritative physics owner"); - } - private static void MissingWorldPhysicsOwnerCannotAdvanceShips() { var entity = Entity(0, 0, "player"); @@ -2586,7 +2685,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks try { AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-missing-world-physics.cc"), run, - new AetheriaRuntimeDaemonTickOptions { FrameId = 1, FixedDeltaSeconds = 0.1, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { FrameId = 1, FixedDeltaSeconds = 0.1, BuildPublications = false }); } catch (InvalidOperationException) { @@ -2605,7 +2704,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var command = AetheriaRuntimeDaemonCommandDocument.Create(AetheriaRuntimeDaemonCommandKinds.SetTractorPower, "pilot", "tractor-smoke", 0, "zone.0.entity.0"); command.CommandId = "tractor-on"; command.ScalarValue = 1; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-tractor-smoke.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.25, SimulationTimeSeconds = 0.25, ObservedCommands = [command], PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.25, SimulationTimeSeconds = 0.25, ObservedCommands = [command], BuildPublications = false }); RequireNear(0.5, ship.TractorPower, 0.000001, "tractor power must use the fossil two-per-second ramp"); Require(pickup.VelocityX < 0 && pickup.PositionX < 60, "Ymir must pull a pickup inside the forward tractor volume toward the ship"); RequireEqual(1, pickup.Item.Quantity, "tractor force must not consume the pickup item"); @@ -2656,7 +2755,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "client pickup commands must not own cargo collection"); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-no-contact.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, BuildPublications = false }); RequireEqual(1, zone.DroppedPickups.Count, "nearby pickup without a Ymir contact fact must remain in the world"); RequireEqual(0, CargoQuantity(ship, salvage.ItemKey), "proximity must not mutate cargo"); @@ -2668,7 +2767,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks NormalX = 1 }; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-dedup.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(contact, contact), FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(contact, contact), FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, BuildPublications = false }); RequireEqual(0, zone.DroppedPickups.Count, "one Ymir contact must consume the pickup"); RequireEqual(1, CargoQuantity(ship, salvage.ItemKey), "duplicate contact facts must commit cargo exactly once"); RequireEqual(1, run.GameEvents.Count(value => value.Kind == "pickup.collected" && value.PickupIndex == 7), @@ -2678,7 +2777,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks zone.DroppedPickups = [new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 8, PositionX = 10, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = salvage.ItemKey, Quantity = 1 }, LifetimeSeconds = 30 }]; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-expiry-smoke.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(), FrameId = 3, FixedDeltaSeconds = 30, SimulationTimeSeconds = 30, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(), FrameId = 3, FixedDeltaSeconds = 30, SimulationTimeSeconds = 30, BuildPublications = false }); RequireEqual(0, zone.DroppedPickups.Count, "pickup must expire after the fossil thirty-second lifetime"); Require(run.GameEvents.Any(value => value.Kind == "pickup.expired" && value.PickupIndex == 8), "daemon lifetime owner must emit authoritative pickup expiry event"); @@ -2700,7 +2799,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var open = Scenario(false); using var openPhysics = new AetheriaYmirWorldPhysics(); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-open.cc"), open, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = openPhysics, FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = openPhysics, FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, BuildPublications = false }); RequireEqual(0, open.Zones[0].DroppedPickups.Count, "shield contact with capacity must collect pickup automatically"); RequireEqual(1, CargoQuantity(open.Zones[0].Entities[0], salvage.ItemKey), "contact collection must commit cargo once"); RequireEqual(1, open.GameEvents.Count(value => value.Kind == "pickup.collected" && value.PickupIndex == 10), @@ -2711,7 +2810,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var full = Scenario(true); using var fullPhysics = new AetheriaYmirWorldPhysics(); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-full.cc"), full, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, BuildPublications = false }); RequireEqual(1, full.Zones[0].DroppedPickups.Count, "full hold must leave contacted pickup alive"); Require(full.Zones[0].DroppedPickups[0].VelocityX > 20, "failed pickup must receive the fossil outward kick"); RequireEqual(1, full.GameEvents.Count(value => value.Kind == "pickup.rejected" && value.PickupIndex == 10), @@ -2719,7 +2818,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RequireEqual(1, full.PickupContactReceipts.Count, "capacity rejection must consume its Box3D Begin fact exactly once"); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-full-second.cc"), full, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, BuildPublications = false }); RequireEqual(1, full.PickupContactReceipts.Count, "persistent or separating contact must not replay an already consumed Begin fact"); RequireEqual(1, full.GameEvents.Count(value => value.Kind == "pickup.rejected" && value.PickupIndex == 10), @@ -2848,5 +2947,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) => new([], [], contacts); + + public AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads( + string runId, + long frameId, + int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + double deltaSeconds) => new(zone.PhysicalPayloads, []); } } diff --git a/Aetheria.State.Daemon/AetheriaYmirPhysicalPayloadPhysics.cs b/Aetheria.State.Daemon/AetheriaYmirPhysicalPayloadPhysics.cs deleted file mode 100644 index 38b59f1e6..000000000 --- a/Aetheria.State.Daemon/AetheriaYmirPhysicalPayloadPhysics.cs +++ /dev/null @@ -1,166 +0,0 @@ -using GameCult.Aetheria.State.Verse; -using Ymir.Core; - -namespace Aetheria.State.Daemon; - -public sealed class AetheriaYmirPhysicalPayloadPhysics : IAetheriaRuntimePhysicalPayloadPhysics -{ - private const string PhysicalPayloadBodyPrefix = "aetheria.physical-payload."; - private const string EntityBodyPrefix = "aetheria.daemon.entity."; - private readonly YmirSimulator _simulator; - - public AetheriaYmirPhysicalPayloadPhysics(YmirSimulator? simulator = null) - { - _simulator = simulator ?? new YmirSimulator(); - } - - public string ImplementationId => "ymir.core"; - - public AetheriaRuntimePhysicalPayloadStep 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.PhysicalPayloads ?? Array.Empty()) - { - if (projectile is not { Active: true }) - continue; - - var projectileBodyId = PhysicalPayloadBodyId(projectile.PayloadId); - var startPosition = new Vec2((float)projectile.PositionX, (float)projectile.PositionZ); - var requestedTranslation = new Vec2( - (float)(projectile.VelocityX * deltaSeconds), - (float)(projectile.VelocityY * deltaSeconds)); - 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, - IsStatic: projectile.Stationary, - 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)); - string? targetBodyId; - Vec2 point; - Vec2 normal; - if (contact != null) - { - targetBodyId = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal) - ? contact.BodyB - : contact.BodyA; - point = contact.Point; - normal = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal) - ? contact.Normal - : new Vec2(-contact.Normal.X, -contact.Normal.Y); - } - else - { - var queryBodies = bodies - .Where(body => !string.Equals(body.Id, projectileBodyId, StringComparison.Ordinal)) - .ToArray(); - var overlapHit = YmirQueries.OverlapCircle(new CircleOverlapQueryRequest( - startPosition, - (float)Math.Max(0.001, projectile.Radius), - queryBodies)) - .Hits.FirstOrDefault(); - var distance = MathF.Sqrt( - requestedTranslation.X * requestedTranslation.X + - requestedTranslation.Y * requestedTranslation.Y); - var castHit = overlapHit != null || distance <= 0.0f - ? null - : YmirQueries.CastCircle(new CircleCastQueryRequest( - startPosition, - requestedTranslation, - distance, - (float)Math.Max(0.001, projectile.Radius), - queryBodies)) - .Hits.FirstOrDefault(); - targetBodyId = overlapHit?.BodyId ?? castHit?.BodyId; - point = overlapHit?.Point ?? castHit?.Point ?? Vec2.Zero; - normal = overlapHit?.Normal ?? castHit?.Normal ?? Vec2.Zero; - } - - if (targetBodyId == null) - { - survivors.Add(projectile); - continue; - } - - if (!entityByBodyId.TryGetValue(targetBodyId, out var target)) - { - survivors.Add(projectile); - continue; - } - - hits.Add(new AetheriaRuntimePhysicalPayloadHit - { - Payload = projectile, - PhysicalPayloadBodyId = projectileBodyId, - TargetEntityIndex = target.EntityIndex, - TargetBodyId = targetBodyId, - PointX = point.X, - PointZ = point.Y, - NormalX = normal.X, - NormalZ = normal.Y - }); - if (string.Equals(projectile.PayloadKind, "mine", StringComparison.Ordinal)) - { - projectile.Stationary = true; - projectile.VelocityX = 0; - projectile.VelocityY = 0; - survivors.Add(projectile); - } - } - - return new AetheriaRuntimePhysicalPayloadStep(survivors, hits); - } - - private static string PhysicalPayloadBodyId(string? projectileId) => - PhysicalPayloadBodyPrefix + (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/AetheriaYmirWorldPhysics.cs b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs index d8ba2a134..e0e1320a8 100644 --- a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs +++ b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs @@ -11,6 +11,7 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi private const string EntityPrefix = "aetheria.daemon.entity."; private const string PickupPrefix = "aetheria.daemon.pickup."; + private const string PhysicalPayloadPrefix = "aetheria.physical-payload."; private readonly Dictionary _sessions = new(); public string ImplementationId => "ymir.box3d.retained-session.v1"; @@ -23,7 +24,8 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi !retainedZones.Contains(key.ZoneIndex)) .ToArray()) { - _sessions[key].Session.Dispose(); + _sessions[key].WorldSession.Dispose(); + _sessions[key].PayloadSession.Dispose(); _sessions.Remove(key); } } @@ -45,7 +47,7 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi if (string.IsNullOrWhiteSpace(stableEntityId)) throw new InvalidOperationException($"Ymir Begin fact '{contact.FactId}' has no stable entity identity."); var entityId = EntityPrefix + stableEntityId; - var bodies = state.Session.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal); + var bodies = state.WorldSession.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal); if (!bodies.TryGetValue(pickupId, out var pickup) || !bodies.TryGetValue(entityId, out var entity)) throw new InvalidOperationException($"Ymir Begin fact '{contact.FactId}' references a body outside its retained session."); @@ -55,15 +57,15 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi contact.NormalZ * normalSign, pickup.Position.X >= entity.Position.X ? 1 : -1, 0); - RequireAccepted(state.Session.SetVelocity(new YmirSetBodyVelocityCommand( - new YmirCommandHeader($"aetheria:fact:{contact.FactId}:pickup-rejection", state.Session.Info.Revision), + RequireAccepted(state.WorldSession.SetVelocity(new YmirSetBodyVelocityCommand( + new YmirCommandHeader($"aetheria:fact:{contact.FactId}:pickup-rejection", state.WorldSession.Info.Revision), pickupId, new Vec2( pickup.Velocity.X + (float)(normal.X * 25.0), pickup.Velocity.Y + (float)(normal.Y * 25.0)), pickup.AngularVelocity))); - var rejected = state.Session.Snapshot().Bodies.Single(body => body.Id == pickupId); + var rejected = state.WorldSession.Snapshot().Bodies.Single(body => body.Id == pickupId); return new AetheriaRuntimeWorldPickupStep { PickupIndex = contact.PickupIndex, @@ -96,7 +98,7 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi (frameId < existing.LastFrameId || (frameId == existing.LastFrameId && simulationStepIndex < existing.LastSimulationStepIndex))) throw new InvalidOperationException( - $"Ymir session '{existing.Session.Info.SessionId}' cannot step backward from " + + $"Ymir session '{existing.WorldSession.Info.SessionId}' cannot step backward from " + $"frame/step {existing.LastFrameId}/{existing.LastSimulationStepIndex} to {frameId}/{simulationStepIndex}."); var active = ActiveEntities(entities); @@ -107,11 +109,11 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi _sessions.Add(key, state); var commandOrdinal = 0; - Reconcile(state.Session, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal); - ApplyTractorForces(state.Session, active, pickups, frameId, simulationStepIndex, ref commandOrdinal); + Reconcile(state.WorldSession, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal); + ApplyTractorForces(state.WorldSession, active, pickups, frameId, simulationStepIndex, ref commandOrdinal); - var step = state.Session.Step(new YmirStepSessionCommand( - Header(state.Session, frameId, simulationStepIndex, "step", "world", ref commandOrdinal), + var step = state.WorldSession.Step(new YmirStepSessionCommand( + Header(state.WorldSession, frameId, simulationStepIndex, "step", "world", ref commandOrdinal), (float)deltaSeconds, GravityFields(zone))); var result = Lower(step, active); @@ -121,10 +123,148 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi return result; } + public AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads( + string runId, + long frameId, + int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + double deltaSeconds) + { + ArgumentNullException.ThrowIfNull(zone); + if (!_sessions.TryGetValue(new WorldKey(runId, zone.ZoneIndex), out var state) || + state.LastFrameId != frameId || state.LastSimulationStepIndex != simulationStepIndex) + throw new InvalidOperationException( + $"Payload phase requires the retained world phase for run '{runId}' zone {zone.ZoneIndex} " + + $"at frame/step {frameId}/{simulationStepIndex}."); + if (state.LastPayloadFrameId == frameId && state.LastPayloadSimulationStepIndex == simulationStepIndex) + return state.LastPayloadResult!; + if (deltaSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(deltaSeconds)); + + var payloads = (zone.PhysicalPayloads ?? Array.Empty()) + .Where(payload => payload != null && payload.Active) + .OrderBy(payload => payload.PayloadId, StringComparer.Ordinal) + .ToArray(); + var desiredBodies = payloads.Select(PayloadBody).ToArray(); + var commandOrdinal = 0; + Reconcile(state.PayloadSession, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal); + var starts = state.PayloadSession.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal); + var stepped = state.PayloadSession.Step(new YmirStepSessionCommand( + Header(state.PayloadSession, frameId, simulationStepIndex, "step", "payloads", ref commandOrdinal), + (float)deltaSeconds, + [])); + var moved = stepped.World.Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal); + var activeEntities = ActiveEntities(entities); + var entitiesByBodyId = activeEntities.ToDictionary(BodyId, StringComparer.Ordinal); + var survivors = new List(); + var hits = new List(); + + foreach (var payload in payloads) + { + var payloadBodyId = PhysicalPayloadPrefix + payload.PayloadId; + var start = starts[payloadBodyId]; + var body = moved[payloadBodyId]; + payload.PositionX = body.Position.X; + payload.PositionZ = body.Position.Y; + payload.VelocityX = body.Velocity.X; + payload.VelocityY = body.Velocity.Y; + + var candidates = activeEntities + .Where(entity => entity.EntityIndex != payload.SourceEntityIndex) + .Select(BodyId) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + var dx = body.Position.X - start.Position.X; + var dz = body.Position.Y - start.Position.Y; + var distance = MathF.Sqrt(dx * dx + dz * dz); + var queryId = $"aetheria:frame:{frameId}:step:{simulationStepIndex}:payload:{payload.PayloadId}"; + CircleCastHit? castHit = null; + CircleOverlapHit? overlapHit = null; + if (distance > 1.0e-6f) + { + var cast = state.WorldSession.CastCircle(new YmirSessionCircleCastQuery( + queryId + ":cast", + state.WorldSession.Info.Revision, + start.Position, + new Vec2(dx, dz), + distance, + (float)payload.Radius, + candidates)); + RequireQuery(cast.Error, cast.QueryId); + castHit = cast.Hits.FirstOrDefault(); + queryId = cast.QueryId; + } + else + { + var overlap = state.WorldSession.OverlapCircle(new YmirSessionCircleOverlapQuery( + queryId + ":overlap", + state.WorldSession.Info.Revision, + body.Position, + (float)payload.Radius, + candidates)); + RequireQuery(overlap.Error, overlap.QueryId); + overlapHit = overlap.Hits.FirstOrDefault(); + queryId = overlap.QueryId; + } + + var targetBodyId = castHit?.BodyId ?? overlapHit?.BodyId; + if (targetBodyId == null || !entitiesByBodyId.TryGetValue(targetBodyId, out var target)) + { + survivors.Add(payload); + continue; + } + + var point = castHit?.Point ?? overlapHit!.Point; + var normal = castHit?.Normal ?? overlapHit!.Normal; + hits.Add(new AetheriaRuntimePhysicalPayloadHit + { + Payload = payload, + QueryId = queryId, + PhysicalPayloadBodyId = payloadBodyId, + TargetEntityIndex = target.EntityIndex, + TargetBodyId = targetBodyId, + PointX = point.X, + PointZ = point.Y, + NormalX = normal.X, + NormalZ = normal.Y + }); + + if (string.Equals(payload.PayloadKind, "mine", StringComparison.Ordinal)) + { + payload.Stationary = true; + payload.VelocityX = 0; + payload.VelocityY = 0; + if (!body.IsStatic) + RequireAccepted(state.PayloadSession.SetVelocity(new YmirSetBodyVelocityCommand( + Header(state.PayloadSession, frameId, simulationStepIndex, "stop", payload.PayloadId, ref commandOrdinal), + payloadBodyId, + Vec2.Zero, + 0))); + survivors.Add(payload); + } + else + { + RequireAccepted(state.PayloadSession.Remove(new YmirRemoveBodyCommand( + Header(state.PayloadSession, frameId, simulationStepIndex, "remove-hit", payload.PayloadId, ref commandOrdinal), + payloadBodyId))); + } + } + + var result = new AetheriaRuntimePhysicalPayloadStep(survivors, hits); + state.LastPayloadFrameId = frameId; + state.LastPayloadSimulationStepIndex = simulationStepIndex; + state.LastPayloadResult = result; + return result; + } + public void Dispose() { foreach (var state in _sessions.Values) - state.Session.Dispose(); + { + state.WorldSession.Dispose(); + state.PayloadSession.Dispose(); + } _sessions.Clear(); } @@ -134,10 +274,15 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi IReadOnlyList bodies) { var sessionId = $"aetheria.run.{key.RunId}.zone.{key.ZoneIndex}"; - return new SessionState(YmirSession.Create(new YmirSessionCreateRequest( - sessionId, - bodies, - (float)zone.SimulationTimeSeconds))); + return new SessionState( + YmirSession.Create(new YmirSessionCreateRequest( + sessionId + ".world", + bodies, + (float)zone.SimulationTimeSeconds)), + YmirSession.Create(new YmirSessionCreateRequest( + sessionId + ".payloads", + [], + (float)zone.SimulationTimeSeconds))); } private static void Reconcile( @@ -163,11 +308,25 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi continue; } + if (present.IsBullet != body.IsBullet || + present.ParticipatesInFields != body.ParticipatesInFields || + present.CollisionCategoryBits != body.CollisionCategoryBits || + present.CollisionMaskBits != body.CollisionMaskBits || + present.CollisionGroupIndex != body.CollisionGroupIndex) + { + RequireAccepted(session.Remove(new YmirRemoveBodyCommand( + Header(session, frameId, simulationStepIndex, "replace-profile", body.Id, ref commandOrdinal), body.Id))); + RequireAccepted(session.Spawn(new YmirSpawnBodyCommand( + Header(session, frameId, simulationStepIndex, "respawn-profile", body.Id, ref commandOrdinal), body))); + continue; + } + if (present.Radius != body.Radius || present.Mass != body.Mass || - present.IsStatic != body.IsStatic || present.Restitution != body.Restitution) + present.IsStatic != body.IsStatic || present.IsKinematic != body.IsKinematic || + present.Restitution != body.Restitution) RequireAccepted(session.Configure(new YmirConfigureBodyCommand( Header(session, frameId, simulationStepIndex, "configure", body.Id, ref commandOrdinal), - body.Id, body.Radius, body.Mass, body.IsStatic, body.Restitution))); + body.Id, body.Radius, body.Mass, body.IsStatic, body.Restitution, body.IsKinematic))); if (present.Direction != body.Direction) RequireAccepted(session.Teleport(new YmirTeleportBodyCommand( @@ -311,6 +470,20 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi private static string BodyId(AetheriaRuntimeEntitySnapshotCommit entity) => EntityPrefix + entity.EntityId; + private static PhysicsBody PayloadBody(AetheriaRuntimePhysicalPayloadCommit payload) => new( + PhysicalPayloadPrefix + payload.PayloadId, + new Vec2((float)payload.PositionX, (float)payload.PositionZ), + payload.Stationary ? Vec2.Zero : new Vec2((float)payload.VelocityX, (float)payload.VelocityY), + (float)payload.Radius, + 1, + IsStatic: payload.Stationary, + Restitution: 0, + Direction: new Vec2(0, 1), + IsBullet: !payload.Stationary, + ParticipatesInFields: false, + CollisionCategoryBits: 2, + CollisionMaskBits: 0); + private static IReadOnlyList GravityFields(AetheriaRuntimeZoneSnapshotCommit zone) => AetheriaRuntimeDaemonRenderQueries.QueryBodyPoses(zone) .Select(pose => (Pose: pose, Body: (zone.Bodies ?? Array.Empty()) @@ -339,6 +512,12 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi $"Ymir rejected Aetheria command '{receipt.CommandId}' with {receipt.Error}."); } + private static void RequireQuery(YmirQueryError error, string queryId) + { + if (error != YmirQueryError.None) + throw new InvalidOperationException($"Ymir rejected Aetheria query '{queryId}' with {error}."); + } + private static int ParseIndex(string id, string prefix) => id.StartsWith(prefix, StringComparison.Ordinal) && int.TryParse(id.AsSpan(prefix.Length), out var value) @@ -355,12 +534,16 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi return length <= 0.001 ? (fallbackX, fallbackY) : (x / length, y / length); } - private sealed class SessionState(YmirSession session) + private sealed class SessionState(YmirSession worldSession, YmirSession payloadSession) { - public YmirSession Session { get; } = session; + public YmirSession WorldSession { get; } = worldSession; + public YmirSession PayloadSession { get; } = payloadSession; public long LastFrameId { get; set; } = -1; public int LastSimulationStepIndex { get; set; } = -1; public AetheriaRuntimeWorldStep? LastResult { get; set; } + public long LastPayloadFrameId { get; set; } = -1; + public int LastPayloadSimulationStepIndex { get; set; } = -1; + public AetheriaRuntimePhysicalPayloadStep? LastPayloadResult { get; set; } } private readonly record struct WorldKey(string RunId, int ZoneIndex); diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 09063c790..79d2b76b4 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -14,7 +14,6 @@ using System.Net.Sockets; var options = AetheriaDaemonHostOptions.Parse(args); var startedAtUtc = DateTimeOffset.UtcNow.ToString("O"); -var physicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics(); using var worldPhysics = new AetheriaYmirWorldPhysics(); var traceClientRudp = string.Equals( Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), @@ -55,7 +54,7 @@ using var clientPumpCancellation = new CancellationTokenSource(); var clientPump = RunClientCultMeshPumpAsync(cultMeshRudpHost, clientPumpCancellation.Token); var nextApiPublicationUtc = DateTimeOffset.UtcNow; var ingressState = new AetheriaDaemonIngressState(); -var firstTick = await TickAsync(node, options, physicalPayloadPhysics, worldPhysics, soaPublisher, latestFrame, ingressState, buildPublications: true).ConfigureAwait(false); +var firstTick = await TickAsync(node, options, worldPhysics, soaPublisher, latestFrame, ingressState, buildPublications: true).ConfigureAwait(false); ThrowIfClientPumpFaulted(clientPump); latestFrame = firstTick.Frame; nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval); @@ -93,7 +92,7 @@ while (!stopped.Task.IsCompleted) } var buildPublications = DateTimeOffset.UtcNow >= nextApiPublicationUtc; - var tick = await TickAsync(node, options, physicalPayloadPhysics, worldPhysics, soaPublisher, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false); + var tick = await TickAsync(node, options, worldPhysics, soaPublisher, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false); ThrowIfClientPumpFaulted(clientPump); latestFrame = tick.Frame; nextTickUtc += options.TickInterval; @@ -136,7 +135,6 @@ static void ThrowIfClientPumpFaulted(Task clientPump) static async Task TickAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, - IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics, IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeDaemonSoaFramePublisher soaPublisher, AetheriaRuntimeDaemonFrameDocument? currentFrame, @@ -250,7 +248,6 @@ static async Task TickAsync( Catalog = ingressState.Catalog, RenderSettings = options.RenderSettings, SimulationSettings = options.SimulationSettings, - PhysicalPayloadPhysics = physicalPayloadPhysics, WorldPhysics = worldPhysics, AdvanceSimulation = advanceSimulation, SimulationStepCount = simulationStepCount, diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 77865277f..6ea173145 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16742,13 +16742,18 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonSimulationSettings.cs"); - var physicalPayloadPhysicsContractPath = Path.Combine( + var worldPhysicsContractPath = Path.Combine( root, "Packages", "org.gamecult.aetheria.state", "Runtime", - "AetheriaRuntimePhysicalPayloadPhysics.cs"); - var ymirPhysicalPayloadPhysicsPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaYmirPhysicalPayloadPhysics.cs"); + "AetheriaRuntimeWorldPhysics.cs"); + var ymirWorldPhysicsPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaYmirWorldPhysics.cs"); + var obsoletePhysicalPayloadAuthorityPaths = new[] + { + Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimePhysicalPayloadPhysics.cs"), + Path.Combine(root, "Aetheria.State.Daemon", "AetheriaYmirPhysicalPayloadPhysics.cs") + }; var daemonProjectPath = Path.Combine(root, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj"); var gameDocumentsPath = Path.Combine( root, @@ -16802,12 +16807,12 @@ 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 physicalPayloadPhysicsContract = File.Exists(physicalPayloadPhysicsContractPath) - ? File.ReadAllText(physicalPayloadPhysicsContractPath) - : throw new InvalidOperationException("Cannot verify daemon projectile authority; shared physics port is missing."); - var ymirPhysicalPayloadPhysics = File.Exists(ymirPhysicalPayloadPhysicsPath) - ? File.ReadAllText(ymirPhysicalPayloadPhysicsPath) - : throw new InvalidOperationException("Cannot verify daemon projectile authority; daemon Ymir adapter is missing."); + var worldPhysicsContract = File.Exists(worldPhysicsContractPath) + ? File.ReadAllText(worldPhysicsContractPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; shared world physics port is missing."); + var ymirWorldPhysics = File.Exists(ymirWorldPhysicsPath) + ? File.ReadAllText(ymirWorldPhysicsPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; retained daemon Ymir owner is missing."); var daemonProject = File.ReadAllText(daemonProjectPath); var gameDocuments = File.Exists(gameDocumentsPath) ? File.ReadAllText(gameDocumentsPath) @@ -16876,7 +16881,7 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) { "public static class AetheriaRuntimeDaemonSimulation", "AetheriaRuntimeDaemonSimulationSettings settings", - "StepCombat(run, zone, entities, intents, deltaSeconds, settings, physicalPayloadPhysics, catalog,", + "StepCombat(run, zone, entities, intents, deltaSeconds, settings, worldPhysics, catalog,", "AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.InstantWeapon)", "EnsureWeaponState(", "CommitWeaponRound(attacker, weapon)", @@ -16889,7 +16894,7 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "CommitEnergy(entity, weapon.Energy)", "CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId)", "PreparePhysicalPayloads(zone, byIndex, deltaSeconds)", - "physicalPayloadPhysics.Step(zone, entities, deltaSeconds)", + "worldPhysics.StepPhysicalPayloads(", "Kind = \"physical-payload.contact\"" }, [daemonSimulationSettingsPath] = new[] @@ -16899,20 +16904,27 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "public double ProjectileSpeed { get; }", "public double AttackRange { get; }" }, - [physicalPayloadPhysicsContractPath] = new[] + [worldPhysicsContractPath] = new[] { - "public interface IAetheriaRuntimePhysicalPayloadPhysics", - "public sealed class AetheriaRuntimePhysicalPayloadPhysicsUnavailable", + "public interface IAetheriaRuntimeWorldPhysics", + "AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads(", "public sealed class AetheriaRuntimePhysicalPayloadStep", "public sealed class AetheriaRuntimePhysicalPayloadHit" }, - [ymirPhysicalPayloadPhysicsPath] = new[] + [ymirWorldPhysicsPath] = new[] { - "public sealed class AetheriaYmirPhysicalPayloadPhysics : IAetheriaRuntimePhysicalPayloadPhysics", - "private readonly YmirSimulator _simulator", - "PhysicalPayloadBodyPrefix", - "EntityBodyPrefix", - "_simulator.Step(new SimulationStepRequest(" + "public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDisposable", + "public YmirSession WorldSession { get; }", + "public YmirSession PayloadSession { get; }", + "state.PayloadSession.Step(new YmirStepSessionCommand(", + "state.WorldSession.CastCircle(new YmirSessionCircleCastQuery(", + "state.WorldSession.OverlapCircle(new YmirSessionCircleOverlapQuery(", + "PhysicalPayloadPrefix + payload.PayloadId", + "Payload phase requires the retained world phase", + "present.IsBullet != body.IsBullet", + "present.ParticipatesInFields != body.ParticipatesInFields", + "present.CollisionCategoryBits != body.CollisionCategoryBits", + "\"respawn-profile\"" }, [daemonProjectPath] = new[] { @@ -16920,8 +16932,8 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) }, [daemonProgramPath] = new[] { - "var physicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics();", - "PhysicalPayloadPhysics = physicalPayloadPhysics" + "using var worldPhysics = new AetheriaYmirWorldPhysics();", + "WorldPhysics = worldPhysics" }, [gameDocumentsPath] = new[] { @@ -16941,8 +16953,8 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) var text = pair.Key == snapshotDocumentsPath ? snapshotDocuments : pair.Key == daemonSimulationPath ? daemonSimulation : pair.Key == daemonSimulationSettingsPath ? daemonSimulationSettings : - pair.Key == physicalPayloadPhysicsContractPath ? physicalPayloadPhysicsContract : - pair.Key == ymirPhysicalPayloadPhysicsPath ? ymirPhysicalPayloadPhysics : + pair.Key == worldPhysicsContractPath ? worldPhysicsContract : + pair.Key == ymirWorldPhysicsPath ? ymirWorldPhysics : pair.Key == daemonProjectPath ? daemonProject : pair.Key == daemonProgramPath ? daemonProgram : pair.Key == gameDocumentsPath ? gameDocuments : @@ -16955,10 +16967,44 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) if (missingPhysicalPayloadAuthoritySymbols.Length > 0) { throw new InvalidOperationException( - "Daemon combat must resolve ordinary shots as receipts while Ymir owns only typed physical payload motion: " + + "Daemon combat must resolve ordinary shots as receipts while the retained Ymir zone owner supplies typed payload motion and query facts: " + string.Join("; ", missingPhysicalPayloadAuthoritySymbols)); } + var forbiddenObsoletePhysicalPayloadAuthorities = new[] + { + "IAetheriaRuntimePhysicalPayloadPhysics", + "AetheriaRuntimePhysicalPayloadPhysicsUnavailable", + "AetheriaYmirPhysicalPayloadPhysics", + "PhysicalPayloadPhysics =", + "private readonly YmirSimulator _simulator" + }; + var survivingObsoletePhysicalPayloadAuthorities = forbiddenObsoletePhysicalPayloadAuthorities + .Where(symbol => daemonSimulation.Contains(symbol, StringComparison.Ordinal) || + worldPhysicsContract.Contains(symbol, StringComparison.Ordinal) || + ymirWorldPhysics.Contains(symbol, StringComparison.Ordinal) || + daemonProgram.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (survivingObsoletePhysicalPayloadAuthorities.Length > 0) + { + throw new InvalidOperationException( + "Physical payloads must not retain a snapshot-world port or adapter beside the retained zone physics owner: " + + string.Join(", ", survivingObsoletePhysicalPayloadAuthorities)); + } + var survivingObsoletePhysicalPayloadAuthorityFiles = obsoletePhysicalPayloadAuthorityPaths + .Where(File.Exists) + .Select(path => Path.GetRelativePath(root, path)) + .ToArray(); + if (survivingObsoletePhysicalPayloadAuthorityFiles.Length > 0) + { + throw new InvalidOperationException( + "The snapshot-world physical payload authority must be deleted, not left available for reinjection: " + + string.Join(", ", survivingObsoletePhysicalPayloadAuthorityFiles)); + } + if (daemonTickRunner.Contains("PhysicalPayloadPhysics", StringComparison.Ordinal)) + throw new InvalidOperationException( + "The daemon tick runner must inject one world physics owner, not a second physical-payload authority."); + var forbiddenPhysicalPayloadDamageSymbols = new[] { "Damage(target, hit.Payload", @@ -16967,8 +17013,8 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) }; var survivingPhysicalPayloadDamageSymbols = forbiddenPhysicalPayloadDamageSymbols .Where(symbol => daemonSimulation.Contains(symbol, StringComparison.Ordinal) || - physicalPayloadPhysicsContract.Contains(symbol, StringComparison.Ordinal) || - ymirPhysicalPayloadPhysics.Contains(symbol, StringComparison.Ordinal)) + worldPhysicsContract.Contains(symbol, StringComparison.Ordinal) || + ymirWorldPhysics.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (survivingPhysicalPayloadDamageSymbols.Length > 0) { diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 0a1cca966..efb12a8c6 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -549,7 +549,7 @@ public class DaemonRuntimeDocumentTests FrameId = 42, SimulationTimeSeconds = 12.5, FixedDeltaSeconds = 0.02, - PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics(), + WorldPhysics = new PassthroughWorldPhysics(), ObservedCommands = new[] { targetCommand, movementCommand } }); @@ -970,7 +970,7 @@ public class DaemonRuntimeDocumentTests FrameId = 43, SimulationTimeSeconds = 12.52, FixedDeltaSeconds = 0.02, - PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics(), + WorldPhysics = new PassthroughWorldPhysics(), ObservedCommands = new[] { targetCommand }, AccountedCommandIds = new[] { targetCommand.CommandId } }); @@ -2062,7 +2062,6 @@ public class DaemonRuntimeDocumentTests new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new PassthroughPhysicalPayloadPhysics(), new PassthroughWorldPhysics()); var zone = FindZone(run, 0); @@ -2165,7 +2164,6 @@ public class DaemonRuntimeDocumentTests new AetheriaRuntimeDaemonIntentState(), 0.1, settings, - new PassthroughPhysicalPayloadPhysics(), new PassthroughWorldPhysics()); var zone = FindZone(run, 0); @@ -2262,7 +2260,7 @@ public class DaemonRuntimeDocumentTests AetheriaRuntimeDaemonSimulation.Step( run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings, - new PassthroughPhysicalPayloadPhysics(), new PassthroughWorldPhysics(), catalog); + new PassthroughWorldPhysics(), catalog); Assert.IsFalse(ship.Contacts.Single(contact => contact.TargetEntityIndex == 3).Visible); Assert.IsFalse(station.Contacts.Single(contact => contact.TargetEntityIndex == 3).Visible, @@ -2417,7 +2415,6 @@ public class DaemonRuntimeDocumentTests new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new PassthroughPhysicalPayloadPhysics(), new PassthroughWorldPhysics()); Assert.IsTrue(pilot.CargoContents.SelectMany(bay => bay.Items).Any(slot => slot.Item.ItemKey == "raider-salvage")); @@ -3276,7 +3273,7 @@ public class DaemonRuntimeDocumentTests FrameId = frame.FrameId, SimulationTimeSeconds = frame.SimulationTimeSeconds, FixedDeltaSeconds = frame.FixedDeltaSeconds, - PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics() + WorldPhysics = new PassthroughWorldPhysics() }); PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame); PublishDaemonSurfacesThroughVerseClient(statePath, tickResult); @@ -3432,7 +3429,7 @@ public class DaemonRuntimeDocumentTests FrameId = frame.FrameId, SimulationTimeSeconds = frame.SimulationTimeSeconds, FixedDeltaSeconds = frame.FixedDeltaSeconds, - PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics() + WorldPhysics = new PassthroughWorldPhysics() }); PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame); PublishDaemonSurfacesThroughVerseClient(statePath, tickResult); @@ -6295,22 +6292,6 @@ public class DaemonRuntimeDocumentTests return document.LatestAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } - private sealed class PassthroughPhysicalPayloadPhysics : IAetheriaRuntimePhysicalPayloadPhysics - { - public string ImplementationId => "test.passthrough"; - - public AetheriaRuntimePhysicalPayloadStep Step( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - double deltaSeconds) - { - return new AetheriaRuntimePhysicalPayloadStep( - zone.PhysicalPayloads, - Array.Empty()); - } - - } - private sealed class PassthroughWorldPhysics : IAetheriaRuntimeWorldPhysics { public string ImplementationId => "test.world-passthrough"; @@ -6336,5 +6317,13 @@ public class DaemonRuntimeDocumentTests }).ToArray(), Array.Empty(), Array.Empty()); } + + public AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads( + string runId, + long frameId, + int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + double deltaSeconds) => new(zone.PhysicalPayloads, Array.Empty()); } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 4db85f2d3..814c7f4a1 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -19,7 +19,6 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonIntentState intents, double deltaSeconds, AetheriaRuntimeDaemonSimulationSettings settings, - IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics, IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeCatalogSnapshot? catalog = null, long frameId = 0, @@ -28,8 +27,6 @@ namespace GameCult.Aetheria.State.Verse { if (run == null || deltaSeconds <= 0) return; - if (physicalPayloadPhysics == null) - throw new ArgumentNullException(nameof(physicalPayloadPhysics)); if (worldPhysics == null) throw new ArgumentNullException(nameof(worldPhysics)); @@ -74,8 +71,8 @@ namespace GameCult.Aetheria.State.Verse run.RunId, frameId, simulationStepIndex, zone, entities, deltaSeconds, worldPhysics); ResolvePickupContacts( run, zone, entities, worldStep.BeginContacts, worldPhysics, catalog, frameId); - StepCombat(run, zone, entities, intents, deltaSeconds, settings, physicalPayloadPhysics, catalog, - frameId, simulationTimeSeconds); + StepCombat(run, zone, entities, intents, deltaSeconds, settings, worldPhysics, catalog, + frameId, simulationTimeSeconds, simulationStepIndex); AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); AetheriaRuntimeSurveySimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); RefreshContacts(entities, settings, catalog); @@ -248,10 +245,11 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonIntentState intents, double deltaSeconds, AetheriaRuntimeDaemonSimulationSettings settings, - IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics, + IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeCatalogSnapshot? catalog, long frameId, - double simulationTimeSeconds) + double simulationTimeSeconds, + int simulationStepIndex) { var byIndex = entities.ToDictionary(entity => entity.EntityIndex); foreach (var entity in entities) @@ -365,11 +363,13 @@ namespace GameCult.Aetheria.State.Verse } PreparePhysicalPayloads(zone, byIndex, deltaSeconds); - var projectileStep = zone.PhysicalPayloads.Count == 0 - ? new AetheriaRuntimePhysicalPayloadStep( - Array.Empty(), - Array.Empty()) - : physicalPayloadPhysics.Step(zone, entities, deltaSeconds); + var projectileStep = worldPhysics.StepPhysicalPayloads( + run.RunId, + frameId, + simulationStepIndex, + zone, + entities, + deltaSeconds); zone.PhysicalPayloads = projectileStep.PhysicalPayloads; TriggerExpiredDeployables(run, zone, byIndex, frameId, simulationTimeSeconds); foreach (var hit in projectileStep.Hits) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs index a99bb45e2..005639ce1 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs @@ -35,8 +35,6 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonRenderSettings.AetheriaDefault; public AetheriaRuntimeDaemonSimulationSettings SimulationSettings { get; set; } = AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault; - public IAetheriaRuntimePhysicalPayloadPhysics PhysicalPayloadPhysics { get; set; } = - AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance; public IAetheriaRuntimeWorldPhysics? WorldPhysics { get; set; } public bool AdvanceSimulation { get; set; } = true; public int SimulationStepCount { get; set; } = 1; @@ -185,7 +183,6 @@ namespace GameCult.Aetheria.State.Verse operationResult.Intents, options.FixedDeltaSeconds, options.SimulationSettings, - options.PhysicalPayloadPhysics, options.WorldPhysics ?? throw new InvalidOperationException("Ymir world physics owner is required."), options.Catalog, options.FrameId, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs deleted file mode 100644 index eefe75851..000000000 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; - -#nullable enable - -namespace GameCult.Aetheria.State.Verse -{ - public interface IAetheriaRuntimePhysicalPayloadPhysics - { - string ImplementationId { get; } - - AetheriaRuntimePhysicalPayloadStep Step( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - double deltaSeconds); - - } - - public sealed class AetheriaRuntimePhysicalPayloadPhysicsUnavailable : IAetheriaRuntimePhysicalPayloadPhysics - { - public static AetheriaRuntimePhysicalPayloadPhysicsUnavailable Instance { get; } = - new AetheriaRuntimePhysicalPayloadPhysicsUnavailable(); - - private AetheriaRuntimePhysicalPayloadPhysicsUnavailable() - { - } - - public string ImplementationId => "unavailable"; - - public AetheriaRuntimePhysicalPayloadStep Step( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - double deltaSeconds) - { - throw new InvalidOperationException( - "Aetheria physical payload simulation requires an injected authoritative physics owner."); - } - } - - public sealed class AetheriaRuntimePhysicalPayloadStep - { - public AetheriaRuntimePhysicalPayloadStep( - IReadOnlyList projectiles, - IReadOnlyList hits) - { - PhysicalPayloads = projectiles ?? Array.Empty(); - Hits = hits ?? Array.Empty(); - } - - public IReadOnlyList PhysicalPayloads { get; } - - public IReadOnlyList Hits { get; } - } - - public sealed class AetheriaRuntimePhysicalPayloadHit - { - public AetheriaRuntimePhysicalPayloadCommit Payload { get; set; } = - new AetheriaRuntimePhysicalPayloadCommit(); - - public string PhysicalPayloadBodyId { 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/AetheriaRuntimeWorldPhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs index 089b4e520..921f29fb5 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs @@ -20,6 +20,13 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds); + AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads( + string runId, + long frameId, + int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + double deltaSeconds); } public sealed class AetheriaRuntimeWorldStep @@ -72,4 +79,32 @@ namespace GameCult.Aetheria.State.Verse public double NormalZ { get; set; } public double RelativeSpeed { get; set; } } + + public sealed class AetheriaRuntimePhysicalPayloadStep + { + public AetheriaRuntimePhysicalPayloadStep( + IReadOnlyList payloads, + IReadOnlyList hits) + { + PhysicalPayloads = payloads ?? Array.Empty(); + Hits = hits ?? Array.Empty(); + } + + public IReadOnlyList PhysicalPayloads { get; } + public IReadOnlyList Hits { get; } + } + + public sealed class AetheriaRuntimePhysicalPayloadHit + { + public AetheriaRuntimePhysicalPayloadCommit Payload { get; set; } = + new AetheriaRuntimePhysicalPayloadCommit(); + public string QueryId { get; set; } = ""; + public string PhysicalPayloadBodyId { 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/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 640afa738..a61026029 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -149,12 +149,14 @@ Current Ymir control flow: means **Not Invented Here**. Box3D owns integration and contact lifecycle. Ymir owns stable game-facing body identity, session revisions, command receipts, and typed contact-fact identity. -- `AetheriaYmirWorldPhysics` owns one retained `YmirSession` for each - `(RunId, ZoneIndex)`. Aetheria owns the active run/zone set and disposes - sessions when a run or zone leaves it. Each fixed simulation substep has its - own physics-step identity even when several substeps share one publication - frame. Entity bodies use stable `EntityId`, never mutable zone-list indices; - current indices are projection data and may change during cross-zone moves. +- `AetheriaYmirWorldPhysics` owns the retained physics transaction for each + `(RunId, ZoneIndex)`: a world session for ships and pickups, and an isolated + payload session for ordnance. Aetheria owns the active run/zone set and + disposes both sessions when a run or zone leaves it. Each fixed simulation + substep has its own physics-step identity even when several substeps share + one publication frame. Entity bodies use stable `EntityId`, never mutable + zone-list indices; current indices are projection data and may change during + cross-zone moves. - Session creation is the only full body bootstrap. Ordinary ticks lower membership changes, movement/orientation changes, tractor forces, gravity fields, and pickup-rejection velocity into explicit retained-session @@ -167,9 +169,11 @@ Current Ymir control flow: - Ordinary direct, constant, and charged weapon fire resolves through deterministic shot receipts. It does not create a Ymir body or derive damage from collision. -- `AetheriaYmirPhysicalPayloadPhysics` advances only objects whose continued - physical motion is gameplay state. Aetheria interprets returned contacts; - Ymir does not own damage policy. +- The payload phase follows the world phase in the same retained zone owner. + Ymir advances payload bodies without world fields or payload-to-payload + response, then revision-checked Box3D circle casts or overlaps query the + current world session against explicit stable entity-body candidates. + Aetheria interprets those query facts; Ymir does not own damage policy. - Authored `DeployableWeapon` behavior now creates retained mine payloads. Aetheria owns deployment admission, arming time, trigger transition, detonation delay, blast selection, damage, and lifecycle events. Ymir moves @@ -191,11 +195,10 @@ This is the right authority direction: daemon physics enters Ymir from daemon state, while Unity only asks Ymir a local presentation-picking question. Remaining debt: -- `AetheriaYmirPhysicalPayloadPhysics` still creates isolated snapshot worlds - for retained mines and other explicitly physical payloads. Payload prepare, - zone step, and consequence resolution must be split so those bodies join the - same retained run/zone session. Until then the retained-session migration is - proven for ships and pickups, not complete for every physical payload. +- World and payload sessions are still in-process organs of the Aetheria + daemon. A future remote Ymir lowering must preserve the same ordered zone + transaction, stable body identities, revisions, and query facts rather than + restoring a second Aetheria physics authority. - The in-process session registry is the live daemon implementation. CultMesh discovery/routing, durable Ymir command receipt storage, and complete Ymir checkpoint reconstruction remain daemon-boundary work; the checkpoint keeps diff --git a/docs/deterministic-combat-resolution.md b/docs/deterministic-combat-resolution.md index ef02874e3..69fdcc291 100644 --- a/docs/deterministic-combat-resolution.md +++ b/docs/deterministic-combat-resolution.md @@ -254,8 +254,8 @@ drop or simplify an effect. It may not promote a visual collision into damage. ## Forbidden Writers -- `IAetheriaRuntimePhysicalPayloadPhysics.Step` may not decide ordinary weapon - damage. +- `IAetheriaRuntimeWorldPhysics.StepPhysicalPayloads` may report Ymir-backed + motion and query facts but may not decide ordinary weapon damage. - Unity physics, effect managers, colliders, and particle systems may not report hits back as combat truth. - cognition and agents may propose shots but may not sample or apply outcomes.