From 9a338045d2442db81731921e5cdca4c1fc69670a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 01:05:25 +0100 Subject: [PATCH] Confine Ymir combat physics to physical payloads --- Aetheria.State.Daemon.Smoke/Program.cs | 90 ++++++--------- ... => AetheriaYmirPhysicalPayloadPhysics.cs} | 73 +++--------- Aetheria.State.Daemon/Program.cs | 10 +- Aetheria.State.Verify/Program.cs | 85 ++++++++------ .../Tests/DaemonRuntimeDocumentTests.cs | 46 +++----- ...AetheriaRuntimeDaemonGameSurfaceBuilder.cs | 12 +- .../AetheriaRuntimeDaemonSimulation.cs | 40 +++---- .../AetheriaRuntimeDaemonTickRunner.cs | 6 +- .../Runtime/AetheriaRuntimeGameDocuments.cs | 14 +-- .../AetheriaRuntimeGameViewportDocuments.cs | 4 +- .../AetheriaRuntimePhysicalPayloadPhysics.cs | 75 ++++++++++++ .../AetheriaRuntimeProjectilePhysics.cs | 107 ------------------ .../AetheriaRuntimeSnapshotDocuments.cs | 12 +- docs/aetheria-current-codebase-model.md | 12 +- docs/deterministic-combat-resolution.md | 18 +-- 15 files changed, 259 insertions(+), 345 deletions(-) rename Aetheria.State.Daemon/{AetheriaYmirProjectilePhysics.cs => AetheriaYmirPhysicalPayloadPhysics.cs} (58%) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs delete mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index b4e1c4024..642b7f6ef 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -4,14 +4,13 @@ using System.Globalization; var checks = new AetheriaDaemonYmirSmokeChecks(); checks.Run(); -Console.WriteLine("Daemon Ymir projectile smoke passed."); +Console.WriteLine("Daemon Ymir physical payload smoke passed."); internal sealed class AetheriaDaemonYmirSmokeChecks { public void Run() { YmirMovesProjectileAndReportsStableContact(); - YmirBeamTraceReturnsFirstSpatialContact(); ConstantWeaponRunsOnDaemonThroughYmirBeamContact(); ChargedWeaponCannotBypassChargeLifecycle(); ChargedWeaponHoldRiskMalfunctionsDeterministically(); @@ -46,27 +45,6 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AgentTowsStationIntoPersistentOrbit(); } - private static void YmirBeamTraceReturnsFirstSpatialContact() - { - var source = Entity(0, 0, "player"); - var blocker = Entity(1, 40, "raider"); - var selectedTarget = Entity(2, 90, "raider"); - var zone = new AetheriaRuntimeZoneSnapshotCommit - { - ZoneIndex = 0, - Entities = [source, blocker, selectedTarget] - }; - var hit = new AetheriaYmirProjectilePhysics().TraceBeam( - zone, zone.Entities, source.EntityIndex, - source.PositionX, source.PositionZ, 1, 0, 150, 0.1); - - Require(hit != null, "Ymir beam trace must report an intersected body"); - RequireEqual(blocker.EntityIndex, hit!.TargetEntityIndex, - "beam trace must return the first spatial contact rather than the selected target"); - Require(hit.Distance > 0 && hit.Distance < 90, - "beam trace must publish the physical impact distance"); - } - private static void ConstantWeaponRunsOnDaemonThroughYmirBeamContact() { var source = Entity(0, 0, "player"); @@ -113,7 +91,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks }); for (var frame = 0; frame < 7; frame++) AetheriaRuntimeDaemonSimulation.Step(run, intents, 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); var state = source.WeaponStates.Single(value => value.BehaviorKind == AetheriaRuntimeBehaviorKinds.ConstantWeapon); @@ -135,10 +113,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 AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), 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"); @@ -191,18 +169,18 @@ internal sealed class AetheriaDaemonYmirSmokeChecks ActorEntityKey = "zone.0.entity.0", WeaponGroup = 0, Fire = true, Active = true }); AetheriaRuntimeDaemonSimulation.Step(run, intents, 0.1, - new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-charged", payload)], [], []), 0, 0); - Require(zone.Projectiles.Count == 0 && !run.GameEvents.Any(value => value.Kind == "shot.committed"), + Require(zone.PhysicalPayloads.Count == 0 && !run.GameEvents.Any(value => value.Kind == "shot.committed"), "pressing an authored ChargedWeapon must not bypass charging through the instant weapon resolver"); Require(Math.Abs(Stat(target, "hull") - 100) < 0.000001, "charged weapon admission must not apply damage before a daemon-owned release commits the shot"); 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 AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-charged", payload)], [], []), frame, frame * 0.1); Require(state.Charging && state.Charged && state.Charge >= 1 && state.ChargeHoldSeconds > 0, @@ -211,7 +189,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 AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), new AetheriaRuntimeCatalogSnapshot([CatalogItem("test-charged", payload)], [], []), 11, 1.1); Require(run.GameEvents.Count(value => value.Kind == "weapon.charge.committed") == 1 && @@ -269,7 +247,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 AetheriaYmirProjectilePhysics(), + new AetheriaRuntimeDaemonSimulationSettings(), new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), catalog, frame, frame * 0.1); } var state = source.WeaponStates.Single(value => value.BehaviorKind == AetheriaRuntimeBehaviorKinds.ChargedWeapon); @@ -689,7 +667,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), - ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, + PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); Require(tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":move", StringComparison.Ordinal)), @@ -799,7 +777,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), - ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, + PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); sawPickup |= tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":pickup", StringComparison.Ordinal)); @@ -929,7 +907,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), Catalog = catalog, - ProjectilePhysics = new AetheriaYmirProjectilePhysics(), + PhysicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics(), BuildPublications = false }); sawTargetCommand |= tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":target", StringComparison.Ordinal)); @@ -982,7 +960,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Require(!target.IsActive, $"attack task must end through daemon shot resolution before presentation projectile contacts; hull={Stat(target, "hull"):0.###} " + $"agent={agent.PositionX:0.###},{agent.PositionZ:0.###} target={target.PositionX:0.###},{target.PositionZ:0.###} " + - $"projectiles={run.Zones[0].Projectiles.Count} task={run.AgentTasks.Single().Status}"); + $"projectiles={run.Zones[0].PhysicalPayloads.Count} task={run.AgentTasks.Single().Status}"); RequireEqual(AetheriaRuntimeAgentTaskStatuses.Completed, run.AgentTasks.Single().Status, "attack task must complete when its target dies"); var shotSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( @@ -1396,7 +1374,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks FixedDeltaSeconds = 0.1, SimulationTimeSeconds = frame * 0.1, ObservedCommands = frame == 0 ? [issue] : Array.Empty(), - ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, + PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); if (frame == 0) @@ -1468,7 +1446,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks operation.Intents, 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - AetheriaRuntimeProjectilePhysicsUnavailable.Instance, + 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"); @@ -1541,13 +1519,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks private static void YmirMovesProjectileAndReportsStableContact() { var (run, zone, target) = Scenario(); - var step = new AetheriaYmirProjectilePhysics().Step(zone, zone.Entities, 0.1); + var step = new AetheriaYmirPhysicalPayloadPhysics().Step(zone, zone.Entities, 0.1); - RequireEqual("ymir.core", new AetheriaYmirProjectilePhysics().AuthorityId, "adapter must identify its owner"); - RequireEqual(0, step.Projectiles.Count, "contacted projectile must not survive"); + RequireEqual("ymir.core", new AetheriaYmirPhysicalPayloadPhysics().AuthorityId, "adapter must identify its owner"); + 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.projectile.smoke-projectile", step.Hits[0].ProjectileBodyId, "projectile body id must be stable"); + 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"); } @@ -1560,12 +1538,12 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new AetheriaYmirProjectilePhysics(), + new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics()); RequireEqual(100.0, Stat(target, "hull"), "presentation projectile contact must not write combat damage"); - RequireEqual(0, run.Zones[0].Projectiles.Count, "spent projectile must leave daemon state"); - RequireEqual(1, run.GameEvents.Count(value => value.Kind == "projectile.impact" && value.SubjectKey == "smoke-projectile"), + RequireEqual(0, run.Zones[0].PhysicalPayloads.Count, "spent projectile must leave daemon state"); + RequireEqual(1, run.GameEvents.Count(value => value.Kind == "physical-payload.contact" && value.SubjectKey == "smoke-projectile"), "Ymir contact must emit one projectile-identity impact event"); RequireEqual(0, run.GameEvents.Count(value => value.Kind == "entity.damaged" && value.TargetEntityIndex == target.EntityIndex), "presentation projectile contact must not manufacture a damage event"); @@ -1573,7 +1551,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeDaemonFrameDocument { FrameId = 0, Run = run }, new AetheriaRuntimeDaemonHealthDocument(), AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon")); - Require(Flatten(feedback.Surface.Root).Any(node => node.Kind == "feedback.event" && node.Props["eventKind"] == "projectile.impact" && node.Props["subjectKey"] == "smoke-projectile"), + Require(Flatten(feedback.Surface.Root).Any(node => node.Kind == "feedback.event" && node.Props["eventKind"] == "physical-payload.contact" && node.Props["subjectKey"] == "smoke-projectile"), "Eve feedback must project authoritative projectile impact identity"); } @@ -1582,7 +1560,7 @@ 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 AetheriaYmirProjectilePhysics(), new AetheriaYmirWorldPhysics(), frameId: 9); + AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, new AetheriaYmirPhysicalPayloadPhysics(), 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), @@ -1599,7 +1577,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - AetheriaRuntimeProjectilePhysicsUnavailable.Instance, + AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, new AetheriaYmirWorldPhysics()); } catch (InvalidOperationException) @@ -1618,7 +1596,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, ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { FrameId = 1, FixedDeltaSeconds = 0.1, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); } catch (InvalidOperationException) { @@ -1637,7 +1615,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], ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.25, SimulationTimeSeconds = 0.25, ObservedCommands = [command], PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, 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"); @@ -1678,7 +1656,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Require(full.RejectedCommandIds.Contains("pickup-full") && zone.DroppedPickups.Count == 1, "full cargo must reject without deleting pickup"); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-expiry-smoke.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 30, SimulationTimeSeconds = 30, ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 30, SimulationTimeSeconds = 30, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, 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"); @@ -1697,7 +1675,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks } var open = Scenario(false); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-open.cc"), open, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, 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), @@ -1705,7 +1683,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks var full = Scenario(true); AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-full.cc"), full, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, 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), @@ -1726,11 +1704,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks { ZoneIndex = 0, Entities = [source, target], - Projectiles = + PhysicalPayloads = [ - new AetheriaRuntimeProjectileCommit + new AetheriaRuntimePhysicalPayloadCommit { - ProjectileId = "smoke-projectile", + PayloadId = "smoke-projectile", SourceEntityIndex = 1, TargetEntityIndex = 2, PositionX = 0, @@ -1738,7 +1716,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks VelocityX = 100, VelocityY = 0, Radius = 1, - Damage = 12, + ContactMagnitude = 12, LifetimeSeconds = 5, Active = true } diff --git a/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs b/Aetheria.State.Daemon/AetheriaYmirPhysicalPayloadPhysics.cs similarity index 58% rename from Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs rename to Aetheria.State.Daemon/AetheriaYmirPhysicalPayloadPhysics.cs index fd1fb67f3..5e1e162e7 100644 --- a/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs +++ b/Aetheria.State.Daemon/AetheriaYmirPhysicalPayloadPhysics.cs @@ -3,20 +3,20 @@ using Ymir.Core; namespace Aetheria.State.Daemon; -public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePhysics +public sealed class AetheriaYmirPhysicalPayloadPhysics : IAetheriaRuntimePhysicalPayloadPhysics { - private const string ProjectileBodyPrefix = "aetheria.projectile."; + private const string PhysicalPayloadBodyPrefix = "aetheria.physical-payload."; private const string EntityBodyPrefix = "aetheria.daemon.entity."; private readonly YmirSimulator _simulator; - public AetheriaYmirProjectilePhysics(YmirSimulator? simulator = null) + public AetheriaYmirPhysicalPayloadPhysics(YmirSimulator? simulator = null) { _simulator = simulator ?? new YmirSimulator(); } public string AuthorityId => "ymir.core"; - public AetheriaRuntimeProjectileStep Step( + public AetheriaRuntimePhysicalPayloadStep Step( AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) @@ -28,15 +28,15 @@ public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePh 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(); + var survivors = new List(); + var hits = new List(); - foreach (var projectile in zone.Projectiles ?? Array.Empty()) + foreach (var projectile in zone.PhysicalPayloads ?? Array.Empty()) { if (projectile is not { Active: true }) continue; - var projectileBodyId = ProjectileBodyId(projectile.ProjectileId); + var projectileBodyId = PhysicalPayloadBodyId(projectile.PayloadId); var bodies = new List { new( @@ -93,10 +93,10 @@ public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePh var normal = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal) ? contact.Normal : new Vec2(-contact.Normal.X, -contact.Normal.Y); - hits.Add(new AetheriaRuntimeProjectileHit + hits.Add(new AetheriaRuntimePhysicalPayloadHit { - Projectile = projectile, - ProjectileBodyId = projectileBodyId, + Payload = projectile, + PhysicalPayloadBodyId = projectileBodyId, TargetEntityIndex = target.EntityIndex, TargetBodyId = targetBodyId, PointX = contact.Point.X, @@ -106,56 +106,11 @@ public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePh }); } - return new AetheriaRuntimeProjectileStep(survivors, hits); + return new AetheriaRuntimePhysicalPayloadStep(survivors, hits); } - public AetheriaRuntimeBeamHit? TraceBeam( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - int sourceEntityIndex, - double originX, - double originZ, - double directionX, - double directionZ, - double range, - double radius) - { - ArgumentNullException.ThrowIfNull(zone); - var entityByBodyId = (entities ?? Array.Empty()) - .Where(entity => entity is { IsActive: true } && entity.EntityIndex != sourceEntityIndex) - .ToDictionary(entity => EntityBodyId(entity.EntityIndex), StringComparer.Ordinal); - var world = new YmirWorld((float)zone.SimulationTimeSeconds, - entityByBodyId.Select(pair => new PhysicsBody( - pair.Key, - new Vec2((float)pair.Value.PositionX, (float)pair.Value.PositionZ), - Vec2.Zero, - ResolveEntityRadius(pair.Value), - 1.0f, - IsStatic: true)).ToArray(), - Array.Empty()); - var hit = _simulator.CastCircle(new CircleCastQueryRequest( - new Vec2((float)originX, (float)originZ), - new Vec2((float)directionX, (float)directionZ), - (float)range, - (float)Math.Max(0, radius), - world)) - .Hits.FirstOrDefault(candidate => entityByBodyId.ContainsKey(candidate.BodyId)); - if (hit == null || !entityByBodyId.TryGetValue(hit.BodyId, out var target)) - return null; - return new AetheriaRuntimeBeamHit - { - TargetEntityIndex = target.EntityIndex, - TargetBodyId = hit.BodyId, - PointX = hit.Point.X, - PointZ = hit.Point.Y, - NormalX = hit.Normal.X, - NormalZ = hit.Normal.Y, - Distance = hit.Distance - }; - } - - private static string ProjectileBodyId(string? projectileId) => - ProjectileBodyPrefix + (projectileId ?? ""); + private static string PhysicalPayloadBodyId(string? projectileId) => + PhysicalPayloadBodyPrefix + (projectileId ?? ""); private static string EntityBodyId(int entityIndex) => EntityBodyPrefix + entityIndex; diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 9b535a297..dfc10d86d 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -13,7 +13,7 @@ using System.Net.Sockets; var options = AetheriaDaemonHostOptions.Parse(args); var startedAtUtc = DateTimeOffset.UtcNow.ToString("O"); -var projectilePhysics = new AetheriaYmirProjectilePhysics(); +var physicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics(); var worldPhysics = new AetheriaYmirWorldPhysics(); Console.WriteLine($"Aetheria Verse daemon starting: {options.StatePath}"); @@ -45,7 +45,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, projectilePhysics, worldPhysics, latestFrame, buildPublications: true).ConfigureAwait(false); +var firstTick = await TickAsync(node, options, physicalPayloadPhysics, worldPhysics, latestFrame, buildPublications: true).ConfigureAwait(false); ThrowIfClientPumpFaulted(clientPump); latestFrame = firstTick.Frame; nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval); @@ -82,7 +82,7 @@ while (!stopped.Task.IsCompleted) } var buildPublications = DateTimeOffset.UtcNow >= nextApiPublicationUtc; - var tick = await TickAsync(node, options, projectilePhysics, worldPhysics, latestFrame, buildPublications).ConfigureAwait(false); + var tick = await TickAsync(node, options, physicalPayloadPhysics, worldPhysics, latestFrame, buildPublications).ConfigureAwait(false); ThrowIfClientPumpFaulted(clientPump); latestFrame = tick.Frame; nextTickUtc += options.TickInterval; @@ -117,7 +117,7 @@ static void ThrowIfClientPumpFaulted(Task clientPump) static async Task TickAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, - IAetheriaRuntimeProjectilePhysics projectilePhysics, + IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics, IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeDaemonFrameDocument? currentFrame, bool buildPublications) @@ -186,7 +186,7 @@ static async Task TickAsync( Catalog = node.RuntimeCatalog().Latest(), RenderSettings = options.RenderSettings, SimulationSettings = options.SimulationSettings, - ProjectilePhysics = projectilePhysics, + PhysicalPayloadPhysics = physicalPayloadPhysics, WorldPhysics = worldPhysics, StarbridgeScenario = starbridgeScenario, StarbridgeSession = starbridgeSession, diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index e76deeece..1f66c8b6b 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16461,13 +16461,13 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonSimulationSettings.cs"); - var projectilePhysicsContractPath = Path.Combine( + var physicalPayloadPhysicsContractPath = Path.Combine( root, "Packages", "org.gamecult.aetheria.state", "Runtime", - "AetheriaRuntimeProjectilePhysics.cs"); - var ymirProjectilePhysicsPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaYmirProjectilePhysics.cs"); + "AetheriaRuntimePhysicalPayloadPhysics.cs"); + var ymirPhysicalPayloadPhysicsPath = 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, @@ -16521,11 +16521,11 @@ 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) + var physicalPayloadPhysicsContract = File.Exists(physicalPayloadPhysicsContractPath) + ? File.ReadAllText(physicalPayloadPhysicsContractPath) : throw new InvalidOperationException("Cannot verify daemon projectile authority; shared physics port is missing."); - var ymirProjectilePhysics = File.Exists(ymirProjectilePhysicsPath) - ? File.ReadAllText(ymirProjectilePhysicsPath) + var ymirPhysicalPayloadPhysics = File.Exists(ymirPhysicalPayloadPhysicsPath) + ? File.ReadAllText(ymirPhysicalPayloadPhysicsPath) : throw new InvalidOperationException("Cannot verify daemon projectile authority; daemon Ymir adapter is missing."); var daemonProject = File.ReadAllText(daemonProjectPath); var gameDocuments = File.Exists(gameDocumentsPath) @@ -16584,18 +16584,18 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) ? File.ReadAllText(freezePath) : throw new InvalidOperationException("Cannot verify daemon transport vocabulary; freeze harness is missing."); - var requiredProjectileAuthoritySymbols = new Dictionary + var requiredPhysicalPayloadAuthoritySymbols = new Dictionary { [snapshotDocumentsPath] = new[] { - "public IReadOnlyList Projectiles", - "public sealed class AetheriaRuntimeProjectileCommit" + "public IReadOnlyList PhysicalPayloads", + "public sealed class AetheriaRuntimePhysicalPayloadCommit" }, [daemonSimulationPath] = new[] { "public static class AetheriaRuntimeDaemonSimulation", "AetheriaRuntimeDaemonSimulationSettings settings", - "StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics, catalog, frameId)", + "StepCombat(run, zone, entities, intents, deltaSeconds, settings, physicalPayloadPhysics, catalog, frameId)", "AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.InstantWeapon)", "EnsureWeaponState(", "CommitWeaponRound(attacker, weapon)", @@ -16606,9 +16606,10 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "weapon.reload.completed", "AetheriaRuntimeCargoTransactions.TryRemoveQuantity(", "CommitEnergy(entity, weapon.Energy)", - "SpawnProjectile(zone, attacker, target, weapon, settings)", - "projectilePhysics.Step(zone, entities, deltaSeconds)", - "Damage(target, hit.Projectile.Damage)" + "CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId)", + "PreparePhysicalPayloads(zone, byIndex, deltaSeconds)", + "physicalPayloadPhysics.Step(zone, entities, deltaSeconds)", + "Kind = \"physical-payload.contact\"" }, [daemonSimulationSettingsPath] = new[] { @@ -16617,18 +16618,18 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "public double ProjectileSpeed { get; }", "public double AttackRange { get; }" }, - [projectilePhysicsContractPath] = new[] + [physicalPayloadPhysicsContractPath] = new[] { - "public interface IAetheriaRuntimeProjectilePhysics", - "public sealed class AetheriaRuntimeProjectilePhysicsUnavailable", - "public sealed class AetheriaRuntimeProjectileStep", - "public sealed class AetheriaRuntimeProjectileHit" + "public interface IAetheriaRuntimePhysicalPayloadPhysics", + "public sealed class AetheriaRuntimePhysicalPayloadPhysicsUnavailable", + "public sealed class AetheriaRuntimePhysicalPayloadStep", + "public sealed class AetheriaRuntimePhysicalPayloadHit" }, - [ymirProjectilePhysicsPath] = new[] + [ymirPhysicalPayloadPhysicsPath] = new[] { - "public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePhysics", + "public sealed class AetheriaYmirPhysicalPayloadPhysics : IAetheriaRuntimePhysicalPayloadPhysics", "private readonly YmirSimulator _simulator", - "ProjectileBodyPrefix", + "PhysicalPayloadBodyPrefix", "EntityBodyPrefix", "_simulator.Step(new SimulationStepRequest(" }, @@ -16638,29 +16639,29 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) }, [daemonProgramPath] = new[] { - "var projectilePhysics = new AetheriaYmirProjectilePhysics();", - "ProjectilePhysics = projectilePhysics" + "var physicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics();", + "PhysicalPayloadPhysics = physicalPayloadPhysics" }, [gameDocumentsPath] = new[] { - "var projectiles = zone.Projectiles", + "var projectiles = zone.PhysicalPayloads", ".Concat(projectiles", "private static AetheriaRuntimeViewportObject ToViewportObject(", - "AetheriaRuntimeProjectileCommit projectile)" + "AetheriaRuntimePhysicalPayloadCommit projectile)" }, [gameViewportDocumentsPath] = new[] { - "public IReadOnlyList Projectiles" + "public IReadOnlyList PhysicalPayloads" } }; - var missingProjectileAuthoritySymbols = requiredProjectileAuthoritySymbols + var missingPhysicalPayloadAuthoritySymbols = requiredPhysicalPayloadAuthoritySymbols .SelectMany(pair => { var text = pair.Key == snapshotDocumentsPath ? snapshotDocuments : pair.Key == daemonSimulationPath ? daemonSimulation : pair.Key == daemonSimulationSettingsPath ? daemonSimulationSettings : - pair.Key == projectilePhysicsContractPath ? projectilePhysicsContract : - pair.Key == ymirProjectilePhysicsPath ? ymirProjectilePhysics : + pair.Key == physicalPayloadPhysicsContractPath ? physicalPayloadPhysicsContract : + pair.Key == ymirPhysicalPayloadPhysicsPath ? ymirPhysicalPayloadPhysics : pair.Key == daemonProjectPath ? daemonProject : pair.Key == daemonProgramPath ? daemonProgram : pair.Key == gameDocumentsPath ? gameDocuments : @@ -16670,11 +16671,29 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) .Select(symbol => $"{Path.GetRelativePath(root, pair.Key)}: missing {symbol}"); }) .ToArray(); - if (missingProjectileAuthoritySymbols.Length > 0) + if (missingPhysicalPayloadAuthoritySymbols.Length > 0) { throw new InvalidOperationException( - "Daemon combat must publish moving typed projectiles instead of invisible local/direct-damage shortcuts: " + - string.Join("; ", missingProjectileAuthoritySymbols)); + "Daemon combat must resolve ordinary shots as receipts while Ymir owns only typed physical payload motion: " + + string.Join("; ", missingPhysicalPayloadAuthoritySymbols)); + } + + var forbiddenPhysicalPayloadDamageSymbols = new[] + { + "Damage(target, hit.Payload", + "SpawnProjectile(zone, attacker", + "TraceBeam(" + }; + var survivingPhysicalPayloadDamageSymbols = forbiddenPhysicalPayloadDamageSymbols + .Where(symbol => daemonSimulation.Contains(symbol, StringComparison.Ordinal) || + physicalPayloadPhysicsContract.Contains(symbol, StringComparison.Ordinal) || + ymirPhysicalPayloadPhysics.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (survivingPhysicalPayloadDamageSymbols.Length > 0) + { + throw new InvalidOperationException( + "Ymir physical payload contacts may report physics facts but may not decide ordinary weapon damage: " + + string.Join(", ", survivingPhysicalPayloadDamageSymbols)); } if (daemonSimulation.Contains("AetheriaRuntimeRtsSimulation", StringComparison.Ordinal) || diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 258a8011a..13b96fbec 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -522,7 +522,7 @@ public class DaemonRuntimeDocumentTests FrameId = 42, SimulationTimeSeconds = 12.5, FixedDeltaSeconds = 0.02, - ProjectilePhysics = new PassthroughProjectilePhysics(), + PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics(), ObservedCommands = new[] { targetCommand, movementCommand } }); @@ -943,7 +943,7 @@ public class DaemonRuntimeDocumentTests FrameId = 43, SimulationTimeSeconds = 12.52, FixedDeltaSeconds = 0.02, - ProjectilePhysics = new PassthroughProjectilePhysics(), + PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics(), ObservedCommands = new[] { targetCommand }, AccountedCommandIds = new[] { targetCommand.CommandId } }); @@ -2033,12 +2033,12 @@ public class DaemonRuntimeDocumentTests new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new PassthroughProjectilePhysics()); + new PassthroughPhysicalPayloadPhysics()); var zone = FindZone(run, 0); - Assert.IsNotEmpty(zone.Projectiles); - Assert.IsTrue(zone.Projectiles.All(projectile => projectile.Active)); - Assert.IsTrue(zone.Projectiles.Any(projectile => projectile.SourceEntityIndex == 0 && projectile.TargetEntityIndex == 1)); + Assert.IsNotEmpty(zone.PhysicalPayloads); + Assert.IsTrue(zone.PhysicalPayloads.All(projectile => projectile.Active)); + Assert.IsTrue(zone.PhysicalPayloads.Any(projectile => projectile.SourceEntityIndex == 0 && projectile.TargetEntityIndex == 1)); Assert.IsTrue(zone.Entities.All(entity => entity.WeaponStates.Any(weapon => weapon.OwnerKind == "daemon-simulation" && weapon.BehaviorKind == "ProjectileWeapon"))); @@ -2060,7 +2060,7 @@ public class DaemonRuntimeDocumentTests var zoneRender = AetheriaRuntimeGameDocuments.ZoneRender(frame); var objectsViewport = AetheriaRuntimeGameDocuments.ObjectsViewport(frame, viewport); - Assert.AreEqual(zone.Projectiles.Count, zoneRender.Projectiles.Count); + Assert.AreEqual(zone.PhysicalPayloads.Count, zoneRender.PhysicalPayloads.Count); Assert.IsTrue(objectsViewport.Objects.Any(obj => obj.Kind == "projectile" && obj.IconAsset.AssetKey == "aetheria.asset.sprite.game.projectile")); @@ -2135,13 +2135,13 @@ public class DaemonRuntimeDocumentTests new AetheriaRuntimeDaemonIntentState(), 0.1, settings, - new PassthroughProjectilePhysics()); + new PassthroughPhysicalPayloadPhysics()); var zone = FindZone(run, 0); - var projectile = zone.Projectiles.Single(projectile => projectile.SourceEntityIndex == 0); + var projectile = zone.PhysicalPayloads.Single(projectile => projectile.SourceEntityIndex == 0); Assert.AreEqual(21, projectile.PositionX, 0.0001); Assert.AreEqual(444, projectile.VelocityX, 0.0001); - Assert.AreEqual(33, projectile.Damage, 0.0001); + Assert.AreEqual(33, projectile.ContactMagnitude, 0.0001); Assert.AreEqual(9, projectile.Radius, 0.0001); Assert.AreEqual(3.5, projectile.LifetimeSeconds, 0.0001); var weapon = zone.Entities[0].WeaponStates.Single(); @@ -2216,7 +2216,7 @@ public class DaemonRuntimeDocumentTests new AetheriaRuntimeDaemonIntentState(), 0.1, AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault, - new PassthroughProjectilePhysics()); + new PassthroughPhysicalPayloadPhysics()); Assert.IsTrue(pilot.CargoContents.SelectMany(bay => bay.Items).Any(slot => slot.Item.ItemKey == "raider-salvage")); Assert.IsEmpty(wreck.CargoContents); @@ -3027,7 +3027,7 @@ public class DaemonRuntimeDocumentTests FrameId = frame.FrameId, SimulationTimeSeconds = frame.SimulationTimeSeconds, FixedDeltaSeconds = frame.FixedDeltaSeconds, - ProjectilePhysics = new PassthroughProjectilePhysics() + PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics() }); PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame); PublishDaemonSurfacesThroughVerseClient(statePath, tickResult); @@ -3183,7 +3183,7 @@ public class DaemonRuntimeDocumentTests FrameId = frame.FrameId, SimulationTimeSeconds = frame.SimulationTimeSeconds, FixedDeltaSeconds = frame.FixedDeltaSeconds, - ProjectilePhysics = new PassthroughProjectilePhysics() + PhysicalPayloadPhysics = new PassthroughPhysicalPayloadPhysics() }); PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame); PublishDaemonSurfacesThroughVerseClient(statePath, tickResult); @@ -5561,29 +5561,19 @@ public class DaemonRuntimeDocumentTests return document.LatestAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } - private sealed class PassthroughProjectilePhysics : IAetheriaRuntimeProjectilePhysics + private sealed class PassthroughPhysicalPayloadPhysics : IAetheriaRuntimePhysicalPayloadPhysics { public string AuthorityId => "test.passthrough"; - public AetheriaRuntimeProjectileStep Step( + public AetheriaRuntimePhysicalPayloadStep Step( AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) { - return new AetheriaRuntimeProjectileStep( - zone.Projectiles, - Array.Empty()); + return new AetheriaRuntimePhysicalPayloadStep( + zone.PhysicalPayloads, + Array.Empty()); } - public AetheriaRuntimeBeamHit TraceBeam( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - int sourceEntityIndex, - double originX, - double originZ, - double directionX, - double directionZ, - double range, - double radius) => null; } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index cb68a4880..fac23e5db 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -586,7 +586,7 @@ namespace GameCult.Aetheria.State.Verse zone, currentEntityKey, simulationSettings)) - .Concat((zone.Projectiles ?? Array.Empty()) + .Concat((zone.PhysicalPayloads ?? Array.Empty()) .Where(projectile => projectile != null && projectile.LifetimeSeconds > 0) .Select(projectile => PlayableWorldProjectile(projectile, run, zone))) .Concat((zone.DroppedPickups ?? Array.Empty()) @@ -766,11 +766,11 @@ namespace GameCult.Aetheria.State.Verse } private static AetheriaRuntimeSurfaceComponent PlayableWorldProjectile( - AetheriaRuntimeProjectileCommit projectile, + AetheriaRuntimePhysicalPayloadCommit projectile, AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone) { - var projectileId = $"{run.RunId}:zone:{zone.ZoneIndex}:projectile:{projectile.ProjectileId}"; + var projectileId = $"{run.RunId}:zone:{zone.ZoneIndex}:physical-payload:{projectile.PayloadId}"; var targetEntityId = projectile.TargetEntityIndex < 0 ? "" : run.EntityRecordKey(zone.ZoneIndex, projectile.TargetEntityIndex); @@ -778,7 +778,7 @@ namespace GameCult.Aetheria.State.Verse { ["entityId"] = projectileId, ["entityKind"] = "projectile", - ["label"] = projectile.WeaponKind ?? "Projectile", + ["label"] = projectile.PayloadKind ?? "Projectile", ["faction"] = "", ["assetRef"] = "prefab.entity.projectile", ["position"] = string.Join(",", new[] @@ -791,13 +791,13 @@ namespace GameCult.Aetheria.State.Verse ["radius"] = Math.Max(0.01, projectile.Radius).ToString("0.###", CultureInfo.InvariantCulture), ["sourceEntityId"] = run.EntityRecordKey(zone.ZoneIndex, projectile.SourceEntityIndex), ["targetEntityId"] = targetEntityId, - ["damage"] = FormatNumber(projectile.Damage), + ["contactMagnitude"] = FormatNumber(projectile.ContactMagnitude), ["remainingLifetime"] = FormatNumber(projectile.LifetimeSeconds), ["selectable"] = "false", ["controllable"] = "false" }; return new AetheriaRuntimeSurfaceComponent( - $"aetheria.daemon.game.world.projectile.{projectile.ProjectileId}", + $"aetheria.daemon.game.world.projectile.{projectile.PayloadId}", "world.entity3d", props, Array.Empty()); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index a0f514545..e35890622 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -17,7 +17,7 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonIntentState intents, double deltaSeconds, AetheriaRuntimeDaemonSimulationSettings settings, - IAetheriaRuntimeProjectilePhysics projectilePhysics, + IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics, IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeCatalogSnapshot? catalog = null, long frameId = 0, @@ -25,8 +25,8 @@ namespace GameCult.Aetheria.State.Verse { if (run == null || deltaSeconds <= 0) return; - if (projectilePhysics == null) - throw new ArgumentNullException(nameof(projectilePhysics)); + if (physicalPayloadPhysics == null) + throw new ArgumentNullException(nameof(physicalPayloadPhysics)); if (worldPhysics == null) throw new ArgumentNullException(nameof(worldPhysics)); @@ -53,7 +53,7 @@ namespace GameCult.Aetheria.State.Verse StepTargetPursuit(entities, settings); var worldStep = StepWorldPhysics(zone, entities, deltaSeconds, worldPhysics); ResolvePickupContacts(run, zone, entities, worldStep, catalog, frameId); - StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics, catalog, frameId); + StepCombat(run, zone, entities, intents, deltaSeconds, settings, physicalPayloadPhysics, catalog, frameId); AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); AetheriaRuntimeSurveySimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); RefreshContacts(entities, settings); @@ -232,7 +232,7 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonIntentState intents, double deltaSeconds, AetheriaRuntimeDaemonSimulationSettings settings, - IAetheriaRuntimeProjectilePhysics projectilePhysics, + IAetheriaRuntimePhysicalPayloadPhysics physicalPayloadPhysics, AetheriaRuntimeCatalogSnapshot? catalog, long frameId) { @@ -332,18 +332,18 @@ namespace GameCult.Aetheria.State.Verse } } - 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; + PreparePhysicalPayloads(zone, byIndex, deltaSeconds); + var projectileStep = zone.PhysicalPayloads.Count == 0 + ? new AetheriaRuntimePhysicalPayloadStep( + Array.Empty(), + Array.Empty()) + : physicalPayloadPhysics.Step(zone, entities, deltaSeconds); + zone.PhysicalPayloads = projectileStep.PhysicalPayloads; foreach (var hit in projectileStep.Hits) { if (byIndex.TryGetValue(hit.TargetEntityIndex, out var target)) { - AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"projectile:{hit.Projectile.ProjectileId}:impact", Kind = "projectile.impact", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = hit.Projectile.SourceEntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = hit.Projectile.ProjectileId, ItemKey = hit.Projectile.WeaponKind, ScalarValue = hit.Projectile.Damage, PositionX = hit.PointX, PositionZ = hit.PointZ }); + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{hit.Payload.PayloadId}:contact", Kind = "physical-payload.contact", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = hit.Payload.SourceEntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = hit.Payload.PayloadId, ItemKey = hit.Payload.PayloadKind, ScalarValue = hit.Payload.ContactMagnitude, PositionX = hit.PointX, PositionZ = hit.PointZ }); } } @@ -767,13 +767,13 @@ namespace GameCult.Aetheria.State.Verse weaponState.LockProgress = Clamp01(weaponState.LockProgress + acquisition); } - private static void PrepareProjectiles( + private static void PreparePhysicalPayloads( AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyDictionary entities, double deltaSeconds) { - var active = new List(); - foreach (var projectile in zone.Projectiles ?? Array.Empty()) + var active = new List(); + foreach (var projectile in zone.PhysicalPayloads ?? Array.Empty()) { if (projectile == null || !projectile.Active) continue; @@ -787,16 +787,16 @@ namespace GameCult.Aetheria.State.Verse entities.TryGetValue(projectile.TargetEntityIndex, out var target) && IsAlive(target)) { - GuideProjectile(projectile, target); + GuidePhysicalPayload(projectile, target); } active.Add(projectile); } - zone.Projectiles = active; + zone.PhysicalPayloads = active; } - private static void GuideProjectile( - AetheriaRuntimeProjectileCommit projectile, + private static void GuidePhysicalPayload( + AetheriaRuntimePhysicalPayloadCommit projectile, AetheriaRuntimeEntitySnapshotCommit target) { var speed = Math.Sqrt( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs index e22cd2da3..519ce1e33 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs @@ -34,8 +34,8 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonRenderSettings.AetheriaDefault; public AetheriaRuntimeDaemonSimulationSettings SimulationSettings { get; set; } = AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault; - public IAetheriaRuntimeProjectilePhysics ProjectilePhysics { get; set; } = - AetheriaRuntimeProjectilePhysicsUnavailable.Instance; + public IAetheriaRuntimePhysicalPayloadPhysics PhysicalPayloadPhysics { get; set; } = + AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance; public IAetheriaRuntimeWorldPhysics? WorldPhysics { get; set; } public bool BuildPublications { get; set; } = true; } @@ -164,7 +164,7 @@ namespace GameCult.Aetheria.State.Verse operationResult.Intents, options.FixedDeltaSeconds, options.SimulationSettings, - options.ProjectilePhysics, + 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/AetheriaRuntimeGameDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs index 22506dd62..01ee7b013 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs @@ -44,7 +44,7 @@ namespace GameCult.Aetheria.State.Verse var run = context.Run; var zone = context.Zone; var entities = zone.Entities ?? Array.Empty(); - var projectiles = zone.Projectiles ?? Array.Empty(); + var projectiles = zone.PhysicalPayloads ?? Array.Empty(); var controlledEntityIndices = entities .Where(IsPlayerControlled) .Select(entity => entity.EntityIndex) @@ -731,7 +731,7 @@ namespace GameCult.Aetheria.State.Verse Bodies = (zone.Bodies ?? Array.Empty()) .Where(body => body != null) .ToArray(), - Projectiles = (zone.Projectiles ?? Array.Empty()) + PhysicalPayloads = (zone.PhysicalPayloads ?? Array.Empty()) .Where(projectile => projectile != null && projectile.Active) .ToArray() }; @@ -804,7 +804,7 @@ namespace GameCult.Aetheria.State.Verse } public static bool IntersectsViewport( - AetheriaRuntimeProjectileCommit projectile, + AetheriaRuntimePhysicalPayloadCommit projectile, AetheriaRuntimeViewportBounds viewport) { return projectile.PositionX >= viewport.MinX && @@ -867,13 +867,13 @@ namespace GameCult.Aetheria.State.Verse } private static AetheriaRuntimeViewportObject ToViewportObject( - AetheriaRuntimeProjectileCommit projectile) + AetheriaRuntimePhysicalPayloadCommit projectile) { return new AetheriaRuntimeViewportObject { EntityIndex = -1, - EntityKey = projectile.ProjectileId ?? "", - DisplayName = projectile.WeaponKind ?? "projectile", + EntityKey = projectile.PayloadId ?? "", + DisplayName = projectile.PayloadKind ?? "projectile", Kind = "projectile", FactionKey = projectile.FactionKey ?? "", X = projectile.PositionX, @@ -889,7 +889,7 @@ namespace GameCult.Aetheria.State.Verse Visibility = projectile.Radius, Status = new AetheriaRuntimeEntityStatus { - Hull = projectile.Damage, + Hull = projectile.ContactMagnitude, Shield = 0, Heat = projectile.AgeSeconds }, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs index d8901b0af..cfdb41b75 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs @@ -894,8 +894,8 @@ namespace GameCult.Aetheria.State.Verse Array.Empty(); [Key(20)] - public IReadOnlyList Projectiles { get; set; } = - Array.Empty(); + public IReadOnlyList PhysicalPayloads { get; set; } = + Array.Empty(); public static string EntityRecordKey(string runId, int zoneIndex, int entityIndex) { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs new file mode 100644 index 000000000..b1797980e --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePhysicalPayloadPhysics.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public interface IAetheriaRuntimePhysicalPayloadPhysics + { + string AuthorityId { 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 AuthorityId => "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/AetheriaRuntimeProjectilePhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs deleted file mode 100644 index d7d80bf73..000000000 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs +++ /dev/null @@ -1,107 +0,0 @@ -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); - - AetheriaRuntimeBeamHit? TraceBeam( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - int sourceEntityIndex, - double originX, - double originZ, - double directionX, - double directionZ, - double range, - double radius); - } - - 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 AetheriaRuntimeBeamHit? TraceBeam( - AetheriaRuntimeZoneSnapshotCommit zone, - IReadOnlyList entities, - int sourceEntityIndex, - double originX, - double originZ, - double directionX, - double directionZ, - double range, - double radius) => throw new InvalidOperationException( - "Aetheria beam tracing 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; } - } - - public sealed class AetheriaRuntimeBeamHit - { - public int TargetEntityIndex { get; set; } = -1; - 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; } - public double Distance { get; set; } - } -} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index 0a0345041..c33ff701a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -561,15 +561,15 @@ namespace GameCult.Aetheria.State.Verse public double SimulationTimeSeconds { get; set; } [Key(17)] - public IReadOnlyList Projectiles { get; set; } = - Array.Empty(); + public IReadOnlyList PhysicalPayloads { get; set; } = + Array.Empty(); } [MessagePackObject] - public sealed class AetheriaRuntimeProjectileCommit + public sealed class AetheriaRuntimePhysicalPayloadCommit { [Key(0)] - public string ProjectileId { get; set; } = ""; + public string PayloadId { get; set; } = ""; [Key(1)] public int SourceEntityIndex { get; set; } = -1; @@ -602,7 +602,7 @@ namespace GameCult.Aetheria.State.Verse public double VelocityY { get; set; } [Key(11)] - public double Damage { get; set; } + public double ContactMagnitude { get; set; } [Key(12)] public double Radius { get; set; } @@ -620,7 +620,7 @@ namespace GameCult.Aetheria.State.Verse public bool Active { get; set; } = true; [Key(17)] - public string WeaponKind { get; set; } = ""; + public string PayloadKind { get; set; } = ""; } [MessagePackObject] diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index ab986d262..4f980a884 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -140,10 +140,14 @@ Unity physics is already fenced as forbidden gameplay authority by Current Ymir control flow: -- Projectiles ask `AetheriaYmirPhysicsBridge.TryStepProjectile`. -- Daemon RTS combat spawns typed projectile state, but projectile motion and - contact discovery route through `AetheriaRuntimeYmirProjectilePhysics` before - Aetheria applies damage policy. Combat does not own projectile kinematics. +- The daemon projects ships, pickups, fields, and explicitly physical payloads + into Ymir. Ymir owns integration, collision, and spatial contact facts. +- 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. - Target/zone hit queries call `TryOverlapTargetHulls`, `TryCastTargetHulls`, `TryOverlapZoneHulls`, or `TryCastZoneHulls`. - `TryBuildDaemonWorld` builds Ymir bodies from `AetheriaDaemonRenderNativeView` diff --git a/docs/deterministic-combat-resolution.md b/docs/deterministic-combat-resolution.md index 323434e8a..c2fd55770 100644 --- a/docs/deterministic-combat-resolution.md +++ b/docs/deterministic-combat-resolution.md @@ -172,7 +172,7 @@ drop or simplify an effect. It may not promote a visual collision into damage. ## Forbidden Writers -- `IAetheriaRuntimeProjectilePhysics.Step` may not decide ordinary weapon +- `IAetheriaRuntimePhysicalPayloadPhysics.Step` may not decide ordinary weapon damage. - Unity physics, effect managers, colliders, and particle systems may not report hits back as combat truth. @@ -182,15 +182,15 @@ drop or simplify an effect. It may not promote a visual collision into damage. ## Cut Line -The current `StepCombat` path that spawns an authoritative projectile, advances -it through `IAetheriaRuntimeProjectilePhysics`, and applies damage from returned -contacts must be removed as the ordinary weapon authority. It is replaced by a -shot resolver and damage transaction. Projectile documents survive only as -derived presentation or as explicitly physical persistent payloads. +Ordinary weapon authority is the shot resolver and damage transaction. It does +not spawn a physical payload, advance one through Ymir, or apply damage from a +returned contact. Physical payload documents exist only for objects whose +continued motion and contact are gameplay state; their contact facts do not +implicitly carry weapon damage authority. -The cut is complete only when a projectile contact can no longer alter ordinary -weapon damage and a missing presentation projectile cannot suppress a resolved -hit. +The cut is complete only when a physical payload contact can no longer alter +ordinary weapon damage and a missing presentation trajectory cannot suppress a +resolved hit. ## Conformance