diff --git a/FS.GG.Game.slnx b/FS.GG.Game.slnx index d5659746..f965063f 100644 --- a/FS.GG.Game.slnx +++ b/FS.GG.Game.slnx @@ -16,5 +16,6 @@ + diff --git a/docs/roadmaps/game-area-portals.md b/docs/roadmaps/game-area-portals.md index 573edbf5..f37abba6 100644 --- a/docs/roadmaps/game-area-portals.md +++ b/docs/roadmaps/game-area-portals.md @@ -112,6 +112,49 @@ assembly compiled. An initial test import failed `FS0892` and was corrected to q module references before the passing run. Native merge readback, broader repository gates, actual P2 transfer, publication and installed/consumer qualification remain open. +## P2 local source qualification — 2026-10-04 + +A local candidate joins the exact qualified P1 topology and Box2D runtime sources; it +is not protected source acceptance. `AreaRuntime` owns active independent local worlds, +validates routed commands before any step, steps every world once in stable area order, +and collects every crossing proposal before staging or removing bodies. It preserves +Runtime's supported circle/box descriptors, material, density, kind, sensor flag and +current awake state. Other filters, damping, gravity scale and sleep configuration +remain Runtime's fixed defaults. Joint-connected bodies refuse before mutation. +Destination placement uses conservative enclosing-disc overlap with non-sensor bodies. +Self-area proposals refuse duplicate staging in this initial slice. + +Known missing-area, capacity, blocked-placement and staging-validation refusals preserve +source ownership. Source removal follows real destination creation; transfer hooks do +not step. An engine or commit uncertainty stops the whole owner, retains its worlds, +and publishes no successful tick. `InspectStopped` offers best-effort actual copied +observations with unreadable worlds explicit as `None`; the last fully published +snapshot remains separate. Hidden solver state and contacts are not restored. + +The actual `examples/Box2D.Portals` entry point ran a circle through the paired 90-degree +portal into a second headless world: one body/entity and one traversal at tick 1, copied +position `(9.9,5.15)`, velocity `(-1,4)` and angular velocity `2`. Both local worlds had +one step; five recorded ticks repeated exactly in fresh worlds under Box2D.NET `3.1.654` +and .NET `10.0.401`, 0.1-second ticks and four substeps. Pose and momentum checks use +absolute `2e-5` tolerance, with angle compared modulo one turn. Source-only replay scope +is the pinned local profile. + +Eleven focused `Box2D AreaRuntime` tests passed: the real scene, asleep descriptor +recreation without stepping, missing/capacity/blocked/joint refusal, actual single-precision +staging-validation refusal, injected commit interruption with cleanup of a real staged +body and explicit stopped observations, exit re-arming after recorded clearance, +interpolation discontinuity and global identity prevalidation. Injected interruption +qualifies that failure path; it does not claim an observed native engine allocator fault. +Retained snapshots are immutable, and transferred entities snap while ordinary entities +continue interpolation. + +The failed compiler/name-resolution attempts and real angle-drift observations were +retained privately. The engine's approximate rotation construction initially compounded +error when recreating a body; staging now installs a direct sine/cosine transform before +preserving awake state, and the real entry passed the original strict tolerance. +Repository gates, generated adapter baselines and native merge readback remain pending. +Publication, installed support, P3 product adoption and browser physics remain open. + ## Later physics and workspace boundaries A crate straddling an opening and colliding on both sides, or a rope/joint spanning diff --git a/examples/Box2D.Portals/Box2D.Portals.fsproj b/examples/Box2D.Portals/Box2D.Portals.fsproj new file mode 100644 index 00000000..cfef67d8 --- /dev/null +++ b/examples/Box2D.Portals/Box2D.Portals.fsproj @@ -0,0 +1,5 @@ + + Exefalse + + + diff --git a/examples/Box2D.Portals/Program.fs b/examples/Box2D.Portals/Program.fs new file mode 100644 index 00000000..1b02a6a4 --- /dev/null +++ b/examples/Box2D.Portals/Program.fs @@ -0,0 +1,12 @@ +module Box2D.Portals.Program + +[] +let main _ = + let observed = Scene.qualify () + let event = observed.Head.Traversals.Head + printfn "PASS portal: tick=%d entity=%s source=%A destination=%A bodies=1 traversals=1 replay=exact" + event.Tick event.Entity event.Source event.Destination + printfn "pose=(%g,%g) velocity=(%g,%g) angularVelocity=%g" + event.DestinationPose.Position.X event.DestinationPose.Position.Y + event.DestinationPose.LinearVelocity.X event.DestinationPose.LinearVelocity.Y event.DestinationPose.AngularVelocity + 0 diff --git a/examples/Box2D.Portals/README.md b/examples/Box2D.Portals/README.md new file mode 100644 index 00000000..0c838c7e --- /dev/null +++ b/examples/Box2D.Portals/README.md @@ -0,0 +1,24 @@ +# Headless rigid portal example + +Run the real `Box2D.Portals` entry point with `dotnet run --project examples/Box2D.Portals`. +It uses Box2D.NET 3.1.654 and .NET 10, one circle, two independent worlds and a paired +90-degree portal. Both worlds step once before transfer. Source removal commits the +same game entity to one destination body; transformed velocities are preserved and +subsequent gravity follows the destination. The scene checks position/velocity and orientation modulo one turn within `2e-5`. +Transfer staging uses a direct sine/cosine rotation to avoid accumulating the pinned +engine's approximate rotation-construction error; angle extraction remains approximate. +Five recorded ticks repeat exactly under +this pinned profile. The program refuses to report success when its observations differ. + +The transfer slice supports Runtime's centred circle/box descriptors and fixed engine +filter/damping/gravity-scale/sleep defaults. It copies current awake state and refuses +joint-connected bodies. Destination placement conservatively tests enclosing-disc +clearance against non-sensor bodies. Missing areas, capacity, blocked placement and +invalid staging descriptors preserve source ownership; uncertain engine effects stop +the owner. Portal-aware interpolation snaps moved entities; re-arming waits until the +prior endpoint clears the body's radius plus declared hysteresis from the exit segment. +Contacts in the destination start at its next ordinary step. + +This source example does not qualify public installed support, product adoption, +browser physics, cross-world contacts/joints, hidden solver rollback or cross-platform +replay. Execution evidence is recorded only after the actual bounded run. diff --git a/examples/Box2D.Portals/Scene.fs b/examples/Box2D.Portals/Scene.fs new file mode 100644 index 00000000..55e89089 --- /dev/null +++ b/examples/Box2D.Portals/Scene.fs @@ -0,0 +1,58 @@ +module Box2D.Portals.Scene + +open System +open FS.GG.Game.Core +open FS.GG.Game.Physics.Box2D + +let areaA = AreaTopology.AreaId "a" +let areaB = AreaTopology.AreaId "b" +let private get = function Ok value -> value | Error reason -> failwith reason +let private point x y : Point = { X = x; Y = y } +let mapping = AreaTopology.rigidMap (Math.PI / 2.) (point 10. 5.) |> get +let sourceOpening : AreaTopology.Opening = { Start = point 0. -2.; Finish = point 0. 2. } +let destinationOpening : AreaTopology.Opening = + { Start = AreaTopology.mapPoint mapping sourceOpening.Start; Finish = AreaTopology.mapPoint mapping sourceOpening.Finish } +let forward : AreaTopology.Portal = + { Id = AreaTopology.PortalId "forward"; Source = areaA; Destination = areaB + SourceOpening = sourceOpening; DestinationOpening = destinationOpening + Direction = AreaTopology.PositiveToNegative; Mapping = mapping; Reverse = Some (AreaTopology.PortalId "back") } +let back : AreaTopology.Portal = + { Id = AreaTopology.PortalId "back"; Source = areaB; Destination = areaA + SourceOpening = destinationOpening; DestinationOpening = sourceOpening + Direction = AreaTopology.NegativeToPositive; Mapping = AreaTopology.inverse mapping; Reverse = Some forward.Id } +let graph = AreaTopology.create [areaA; areaB] [forward; back] |> get +let settings = + [ { Area = areaA; Physics = { Physics.defaultSettings with Gravity = point 0. 0.; TickSeconds = 0.1 }; Capacity = 16 } + { Area = areaB; Physics = { Physics.defaultSettings with Gravity = point 0. -3.; TickSeconds = 0.1 }; Capacity = 16 } ] +let moving = + { Physics.circle "traveller" 0.1 (point -0.25 0.) with + LinearVelocity = point 4. 1.; Rotation = 0.3; AngularVelocity = 2. + Density = 2.; Friction = 0.4; Restitution = 0.2 } +let inputs = + [ [ { Area = areaA; Command = CreateBody moving } ]; []; []; []; [] ] +let advance = function Advanced snapshot -> snapshot | Stopped(reason, _) -> failwith ("Stopped: " + reason) +let run () = + use runtime = new AreaRuntime(graph, settings, 0.05) + inputs |> List.map (runtime.Step >> advance) +let qualify () = + let observed = run () + if observed <> run () then failwith "Pinned-profile portal replay differs." + let transferred = observed.Head + let event = match transferred.Traversals with [event] -> event | _ -> failwith "Expected one committed traversal." + if transferred.Ownership <> Map.ofList [moving.Entity, areaB] then failwith "Entity identity/ownership differs." + if transferred.Areas |> Map.toSeq |> Seq.sumBy (fun (_, snapshot) -> snapshot.Bodies.Count) <> 1 then + failwith "Expected exactly one live body after commit." + if transferred.Areas |> Map.exists (fun _ snapshot -> snapshot.Tick <> 1L) then failwith "A local world stepped more than once." + let sourceMotion : AreaTopology.Motion = + { Position = event.SourcePose.Position; Velocity = event.SourcePose.LinearVelocity + Orientation = event.SourcePose.Rotation; AngularVelocity = event.SourcePose.AngularVelocity } + let expected = AreaTopology.mapMotion mapping sourceMotion + let close a b = abs (a - b) <= 2e-5 + let pose = event.DestinationPose + if not (close pose.Position.X expected.Position.X && close pose.Position.Y expected.Position.Y + && close pose.LinearVelocity.X expected.Velocity.X && close pose.LinearVelocity.Y expected.Velocity.Y + && close pose.AngularVelocity expected.AngularVelocity + && close (Math.IEEERemainder(pose.Rotation - expected.Orientation, 2. * Math.PI)) 0.) then + failwithf "Rotated pose/momentum differ. actual=%A expected=%A source=%A" pose expected event.SourcePose + if observed |> List.sumBy (fun snapshot -> snapshot.Traversals.Length) <> 1 then failwith "Unexpected repeated transfer." + observed diff --git a/examples/Box2D.Portals/packages.lock.json b/examples/Box2D.Portals/packages.lock.json new file mode 100644 index 00000000..ab8d8e25 --- /dev/null +++ b/examples/Box2D.Portals/packages.lock.json @@ -0,0 +1,33 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "FSharp.Core": { + "type": "Direct", + "requested": "[10.1.302, )", + "resolved": "10.1.302", + "contentHash": "bN7XMQrAQg1dyHScJ7FcsznERBkvm74LtIo1O6C5mUzgbDqSwNbDpb3+nBGaJAjDmtC/suK5Vx5pFysKY6Hm+Q==" + }, + "fs.gg.game.core": { + "type": "Project", + "dependencies": { + "FSharp.Core": "[10.1.302, )" + } + }, + "fs.gg.game.physics.box2d": { + "type": "Project", + "dependencies": { + "Box2D.NET": "[3.1.654, )", + "FS.GG.Game.Core": "[0.16.0, )", + "FSharp.Core": "[10.1.302, )" + } + }, + "Box2D.NET": { + "type": "CentralTransitive", + "requested": "[3.1.654, )", + "resolved": "3.1.654", + "contentHash": "Sh43ZKml3BPnhGD9FzvHfBLcLR1LDUN1iMJnnX2dJJam15+DG2nLRezPlxjdXWfrskg0wEJpgdNVEkNnHx9wwA==" + } + } + } +} \ No newline at end of file diff --git a/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt b/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt index db4f8f70..1627e164 100644 --- a/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt +++ b/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt @@ -1,3 +1,12 @@ +FS.GG.Game.Physics.Box2D.AreaCommand +FS.GG.Game.Physics.Box2D.AreaPhysics +FS.GG.Game.Physics.Box2D.AreaRuntime +FS.GG.Game.Physics.Box2D.AreaSettings +FS.GG.Game.Physics.Box2D.AreaSnapshot +FS.GG.Game.Physics.Box2D.AreaStepResult +FS.GG.Game.Physics.Box2D.AreaStepResult+Advanced +FS.GG.Game.Physics.Box2D.AreaStepResult+Stopped +FS.GG.Game.Physics.Box2D.AreaStepResult+Tags FS.GG.Game.Physics.Box2D.BodyDescriptor FS.GG.Game.Physics.Box2D.BodyKind FS.GG.Game.Physics.Box2D.BodyKind+Tags @@ -18,6 +27,7 @@ FS.GG.Game.Physics.Box2D.PhysicsEvent+SensorEntered FS.GG.Game.Physics.Box2D.PhysicsEvent+SensorExited FS.GG.Game.Physics.Box2D.PhysicsEvent+Tags FS.GG.Game.Physics.Box2D.Pose +FS.GG.Game.Physics.Box2D.RefusedTraversal FS.GG.Game.Physics.Box2D.Runtime FS.GG.Game.Physics.Box2D.Settings FS.GG.Game.Physics.Box2D.Shape @@ -25,3 +35,7 @@ FS.GG.Game.Physics.Box2D.Shape+Box FS.GG.Game.Physics.Box2D.Shape+Circle FS.GG.Game.Physics.Box2D.Shape+Tags FS.GG.Game.Physics.Box2D.Snapshot +FS.GG.Game.Physics.Box2D.TransferRefusal +FS.GG.Game.Physics.Box2D.TransferRefusal+StagingRejected +FS.GG.Game.Physics.Box2D.TransferRefusal+Tags +FS.GG.Game.Physics.Box2D.Traversal diff --git a/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt b/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt index f5ca4140..5aabc072 100644 --- a/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt +++ b/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt @@ -1,3 +1,23 @@ +FS.GG.Game.Physics.Box2D.AreaCommand.Area : FS.GG.Game.Core.AreaTopology+AreaId [get] +FS.GG.Game.Physics.Box2D.AreaCommand.Command : FS.GG.Game.Physics.Box2D.Command [get] +FS.GG.Game.Physics.Box2D.AreaCommand.new(FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Command) +FS.GG.Game.Physics.Box2D.AreaPhysics.interpolate(System.Double, FS.GG.Game.Physics.Box2D.AreaSnapshot, FS.GG.Game.Physics.Box2D.AreaSnapshot) : Microsoft.FSharp.Collections.FSharpMap> +FS.GG.Game.Physics.Box2D.AreaRuntime.InspectStopped() : Microsoft.FSharp.Collections.FSharpMap> +FS.GG.Game.Physics.Box2D.AreaRuntime.Snapshot() : FS.GG.Game.Physics.Box2D.AreaSnapshot +FS.GG.Game.Physics.Box2D.AreaRuntime.Step(Microsoft.FSharp.Collections.FSharpList) : FS.GG.Game.Physics.Box2D.AreaStepResult +FS.GG.Game.Physics.Box2D.AreaRuntime.new(FS.GG.Game.Core.AreaTopology+Graph, Microsoft.FSharp.Collections.FSharpList, System.Double) +FS.GG.Game.Physics.Box2D.AreaSettings.Area : FS.GG.Game.Core.AreaTopology+AreaId [get] +FS.GG.Game.Physics.Box2D.AreaSettings.Capacity : System.Int32 [get] +FS.GG.Game.Physics.Box2D.AreaSettings.Physics : FS.GG.Game.Physics.Box2D.Settings [get] +FS.GG.Game.Physics.Box2D.AreaSettings.new(FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Settings, System.Int32) +FS.GG.Game.Physics.Box2D.AreaSnapshot.Areas : Microsoft.FSharp.Collections.FSharpMap [get] +FS.GG.Game.Physics.Box2D.AreaSnapshot.Ownership : Microsoft.FSharp.Collections.FSharpMap [get] +FS.GG.Game.Physics.Box2D.AreaSnapshot.Refused : Microsoft.FSharp.Collections.FSharpList [get] +FS.GG.Game.Physics.Box2D.AreaSnapshot.Tick : System.Int64 [get] +FS.GG.Game.Physics.Box2D.AreaSnapshot.Traversals : Microsoft.FSharp.Collections.FSharpList [get] +FS.GG.Game.Physics.Box2D.AreaSnapshot.new(System.Int64, Microsoft.FSharp.Collections.FSharpMap, Microsoft.FSharp.Collections.FSharpMap, Microsoft.FSharp.Collections.FSharpList, Microsoft.FSharp.Collections.FSharpList) +FS.GG.Game.Physics.Box2D.AreaStepResult+Tags.Advanced : System.Int32 +FS.GG.Game.Physics.Box2D.AreaStepResult+Tags.Stopped : System.Int32 FS.GG.Game.Physics.Box2D.BodyDescriptor.AngularVelocity : System.Double [get] FS.GG.Game.Physics.Box2D.BodyDescriptor.Density : System.Double [get] FS.GG.Game.Physics.Box2D.BodyDescriptor.Entity : System.String [get] @@ -36,6 +56,10 @@ FS.GG.Game.Physics.Box2D.Pose.LinearVelocity : FS.GG.Game.Core.Point [get] FS.GG.Game.Physics.Box2D.Pose.Position : FS.GG.Game.Core.Point [get] FS.GG.Game.Physics.Box2D.Pose.Rotation : System.Double [get] FS.GG.Game.Physics.Box2D.Pose.new(FS.GG.Game.Core.Point, System.Double, FS.GG.Game.Core.Point, System.Double) +FS.GG.Game.Physics.Box2D.RefusedTraversal.Entity : System.String [get] +FS.GG.Game.Physics.Box2D.RefusedTraversal.Portal : FS.GG.Game.Core.AreaTopology+PortalId [get] +FS.GG.Game.Physics.Box2D.RefusedTraversal.Reason : FS.GG.Game.Physics.Box2D.TransferRefusal [get] +FS.GG.Game.Physics.Box2D.RefusedTraversal.new(System.String, FS.GG.Game.Core.AreaTopology+PortalId, FS.GG.Game.Physics.Box2D.TransferRefusal) FS.GG.Game.Physics.Box2D.Runtime.Settings : FS.GG.Game.Physics.Box2D.Settings [get] FS.GG.Game.Physics.Box2D.Runtime.Snapshot() : FS.GG.Game.Physics.Box2D.Snapshot FS.GG.Game.Physics.Box2D.Runtime.Step(Microsoft.FSharp.Collections.FSharpList) : FS.GG.Game.Physics.Box2D.Snapshot @@ -50,3 +74,16 @@ FS.GG.Game.Physics.Box2D.Snapshot.Bodies : Microsoft.FSharp.Collections.FSharpMa FS.GG.Game.Physics.Box2D.Snapshot.Events : Microsoft.FSharp.Collections.FSharpList [get] FS.GG.Game.Physics.Box2D.Snapshot.Tick : System.Int64 [get] FS.GG.Game.Physics.Box2D.Snapshot.new(System.Int64, Microsoft.FSharp.Collections.FSharpMap, Microsoft.FSharp.Collections.FSharpList) +FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.CapacityExceeded : System.Int32 +FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.DestinationBlocked : System.Int32 +FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.JointConnected : System.Int32 +FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.MissingDestination : System.Int32 +FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.StagingRejected : System.Int32 +FS.GG.Game.Physics.Box2D.Traversal.Destination : FS.GG.Game.Core.AreaTopology+AreaId [get] +FS.GG.Game.Physics.Box2D.Traversal.DestinationPose : FS.GG.Game.Physics.Box2D.Pose [get] +FS.GG.Game.Physics.Box2D.Traversal.Entity : System.String [get] +FS.GG.Game.Physics.Box2D.Traversal.Portal : FS.GG.Game.Core.AreaTopology+PortalId [get] +FS.GG.Game.Physics.Box2D.Traversal.Source : FS.GG.Game.Core.AreaTopology+AreaId [get] +FS.GG.Game.Physics.Box2D.Traversal.SourcePose : FS.GG.Game.Physics.Box2D.Pose [get] +FS.GG.Game.Physics.Box2D.Traversal.Tick : System.Int64 [get] +FS.GG.Game.Physics.Box2D.Traversal.new(System.Int64, FS.GG.Game.Core.AreaTopology+PortalId, System.String, FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Pose, FS.GG.Game.Physics.Box2D.Pose) diff --git a/src/Game.Physics.Box2D/AreaRuntime.fs b/src/Game.Physics.Box2D/AreaRuntime.fs new file mode 100644 index 00000000..933f2b5d --- /dev/null +++ b/src/Game.Physics.Box2D/AreaRuntime.fs @@ -0,0 +1,217 @@ +namespace FS.GG.Game.Physics.Box2D + +open System +open FS.GG.Game.Core +open FS.GG.Game.Physics.Box2D + +/// An independent active local world with a whole-body capacity, including static/sensor bodies. +type AreaSettings = { Area: AreaTopology.AreaId; Physics: Settings; Capacity: int } +/// A game command routed to exactly one active local world. +type AreaCommand = { Area: AreaTopology.AreaId; Command: Command } +/// Copied whole-body traversal, published only after source removal and ownership commit. +type Traversal = + { Tick: int64; Portal: AreaTopology.PortalId; Entity: string + Source: AreaTopology.AreaId; Destination: AreaTopology.AreaId + SourcePose: Pose; DestinationPose: Pose } +/// A known refusal preserves source ownership; uncertain engine mutation stops the whole owner. +type TransferRefusal = + | MissingDestination | CapacityExceeded | DestinationBlocked | JointConnected | StagingRejected of reason: string +/// A refused proposal for one entity during one tick. +type RefusedTraversal = { Entity: string; Portal: AreaTopology.PortalId; Reason: TransferRefusal } +/// Immutable copied observations after all local steps and transfers. +type AreaSnapshot = + { Tick: int64; Areas: Map + Ownership: Map; Traversals: Traversal list; Refused: RefusedTraversal list } +/// Explicit failure disposition. A stopped result is never successful acceptance and may contain +/// staged duplicates; LastKnown is the last fully published immutable state, not a rollback promise. +type AreaStepResult = Advanced of AreaSnapshot | Stopped of reason: string * lastKnown: AreaSnapshot + +/// Owns independent Runtime instances and whole-body transfer transactions on one simulation owner. +[] +type AreaRuntime(graph: AreaTopology.Graph, settings: AreaSettings list, hysteresis: float) = + let configs = settings |> List.map (fun s -> s.Area, s) |> Map.ofList + do + if configs.Count <> settings.Length || settings.IsEmpty then invalidArg "settings" "Active areas must be unique and nonempty." + if not (Double.IsFinite hysteresis) || hysteresis <= 0. then invalidArg "hysteresis" "Hysteresis must be finite and positive." + if settings |> List.exists (fun s -> s.Capacity < 1) then invalidArg "settings" "Capacity must be positive." + if settings |> List.exists (fun s -> s.Physics.TickSeconds <> settings.Head.Physics.TickSeconds) then + invalidArg "settings" "All active worlds must use the same fixed interval." + let worlds = + let mutable created = Map.empty + try + for KeyValue(area, config) in configs do created <- Map.add area (new Runtime(config.Physics)) created + created + with _ -> + for KeyValue(_, world) in created do (world :> IDisposable).Dispose() + reraise () + let mutable disposed = false + let mutable stopped = false + let mutable commitFault: (unit -> unit) option = None + let mutable tick = 0L + let mutable blocked: Map = Map.empty + let snapshots () = worlds |> Map.map (fun _ world -> world.Snapshot()) + let ownership areas = + areas |> Map.toList |> List.collect (fun (area, snapshot: Snapshot) -> snapshot.Bodies |> Map.toList |> List.map (fun (entity, _) -> entity, area)) |> Map.ofList + let initialAreas = snapshots () + let mutable last = { Tick = 0L; Areas = initialAreas; Ownership = Map.empty; Traversals = []; Refused = [] } + let ensureActive () = + if disposed then raise (ObjectDisposedException(nameof AreaRuntime)) + if stopped then invalidOp "Area simulation is stopped; dispose it." + let radius (descriptor: BodyDescriptor) = match descriptor.Shape with Circle r -> r | Box(w, h) -> sqrt (w * w + h * h) + let pose (descriptor: BodyDescriptor) : Pose = + { Position = descriptor.Position; Rotation = descriptor.Rotation + LinearVelocity = descriptor.LinearVelocity; AngularVelocity = descriptor.AngularVelocity } + let clear (point: Point) (opening: AreaTopology.Opening) clearance = + let dx, dy = opening.Finish.X - opening.Start.X, opening.Finish.Y - opening.Start.Y + let length2 = dx * dx + dy * dy + let along = max 0. (min 1. (((point.X - opening.Start.X) * dx + (point.Y - opening.Start.Y) * dy) / length2)) + let x, y = opening.Start.X + along * dx, opening.Start.Y + along * dy + let px, py = point.X - x, point.Y - y + px * px + py * py > clearance * clearance + let placementAllowed destination descriptor = + let current = worlds[destination].Snapshot() + current.Bodies |> Map.forall (fun entity _ -> + let other, _, _ = worlds[destination].Describe entity + if other.IsSensor || descriptor.IsSensor then true + else + let x, y = other.Position.X - descriptor.Position.X, other.Position.Y - descriptor.Position.Y + let sum = radius descriptor + radius other + x * x + y * y >= sum * sum) + let stop reason = + stopped <- true + worlds |> Map.iter (fun _ world -> world.Stop()) + Stopped(reason, last) + + member _.Snapshot() = ensureActive (); last + member _.InspectStopped() = + if disposed then raise (ObjectDisposedException(nameof AreaRuntime)) + if not stopped then invalidOp "Inspection is for a stopped owner." + worlds |> Map.map (fun _ world -> world.Inspect()) + member internal _.CommitFault with set value = commitFault <- value + member internal _.SetAwake(area, entity, awake) = ensureActive (); worlds[area].SetAwake(entity, awake) + member internal _.BodyState(area, entity) = ensureActive (); worlds[area].Describe entity + member internal _.LocalTicks = worlds |> Map.map (fun _ world -> world.Snapshot().Tick) + + member _.Step(commands: AreaCommand list) = + ensureActive () + // Validate the entire global batch before stepping any local world. + let batches = worlds |> Map.map (fun area _ -> commands |> List.choose (fun c -> if c.Area = area then Some c.Command else None)) + for command in commands do + if not (Map.containsKey command.Area worlds) then invalidArg "commands" "Unknown active area." + let mutable ids = last.Ownership |> Map.map (fun _ _ -> ()) + let mutable removed = Set.empty + for command in commands do + match command.Command with + | CreateBody body -> + if Map.containsKey body.Entity ids || Set.contains body.Entity removed then invalidArg "commands" "Duplicate/reused global entity ID." + ids <- Map.add body.Entity () ids + | RemoveBody entity -> ids <- Map.remove entity ids; removed <- Set.add entity removed + | _ -> () + worlds |> Map.iter (fun area world -> world.ValidateBatch batches[area]) + for KeyValue(area, world) in worlds do + let mutable count = world.Snapshot().Bodies.Count + for command in batches[area] do + match command with + | CreateBody _ -> count <- count + 1 + | RemoveBody _ -> count <- count - 1 + | _ -> () + if count > configs[area].Capacity then invalidArg "commands" "Area capacity exceeded." + try + let previous = snapshots () + let stepped = worlds |> Map.map (fun area world -> world.Step batches[area]) + tick <- tick + 1L + let mutable refusals = [] + let mutable traversals = [] + // Collect every proposal before any world is mutated. Stable area/entity ordering. + let candidates = + [ for KeyValue(area, snapshot) in stepped do + for KeyValue(entity, current) in snapshot.Bodies do + let descriptor, awake, jointed = worlds[area].Describe entity + let prior = + match Map.tryFind entity previous[area].Bodies with + | Some old -> Some old.Position + | None -> commands |> List.tryPick (fun c -> + match c.Command with + | CreateBody body when c.Area = area && body.Entity = entity -> Some body.Position + | _ -> None) + match Map.tryFind entity blocked with + | Some (_, opening, clearance) when prior |> Option.exists (fun position -> clear position opening clearance) -> blocked <- Map.remove entity blocked + | _ -> () + let excluded = match Map.tryFind entity blocked with Some (id, _, _) -> Set.singleton id | None -> Set.empty + match prior with + | None -> () + | Some position -> + let motion : AreaTopology.Motion = + { Position = current.Position; Velocity = current.LinearVelocity + Orientation = current.Rotation; AngularVelocity = current.AngularVelocity } + match AreaTopology.tryCrossing graph area excluded (radius descriptor) position motion with + | Some crossing -> yield entity, descriptor, awake, jointed, crossing + | None -> () ] + for entity, descriptor, awake, jointed, crossing in candidates do + let portal = crossing.Portal + let mapped = crossing.MappedMotion + let destination = + { descriptor with Position = mapped.Position; Rotation = mapped.Orientation + LinearVelocity = mapped.Velocity; AngularVelocity = mapped.AngularVelocity } + let refusal = + if jointed then Some JointConnected + elif not (Map.containsKey portal.Destination worlds) then Some MissingDestination + elif worlds[portal.Destination].Snapshot().Bodies.Count >= configs[portal.Destination].Capacity then Some CapacityExceeded + elif not (placementAllowed portal.Destination destination) then Some DestinationBlocked + else None + let mutable rejection = refusal + if rejection.IsNone then + try worlds[portal.Destination].ValidateBatch [CreateBody destination] + with :? ArgumentException as error -> rejection <- Some (StagingRejected error.Message) + match rejection with + | Some reason -> refusals <- { Entity = entity; Portal = portal.Id; Reason = reason } :: refusals + | None -> + let target, source = worlds[portal.Destination], worlds[portal.Source] + // Keep source until destination body/fixture/awake state exist. Never step staged bodies. + target.Stage(destination, awake) + try + commitFault |> Option.iter (fun fault -> fault ()) + source.RemoveWithoutStep entity + with _ -> + try target.RemoveWithoutStep entity + with _ -> target.Stop() + reraise () + let actual, actualAwake, _ = target.Describe entity + if actualAwake <> awake && descriptor.Kind = Dynamic then invalidOp "Destination awake state differs; simulation stopped." + let finalPose = pose actual + traversals <- { Tick = tick; Portal = portal.Id; Entity = entity + Source = portal.Source; Destination = portal.Destination + SourcePose = pose descriptor; DestinationPose = finalPose } :: traversals + match portal.Reverse with + | Some reverse -> blocked <- Map.add entity (reverse, portal.DestinationOpening, radius descriptor + hysteresis) blocked + | None -> blocked <- Map.remove entity blocked + let areas = snapshots () |> Map.map (fun area snapshot -> { snapshot with Events = stepped[area].Events }) + let owners = ownership areas + if owners.Count <> (areas |> Map.toSeq |> Seq.sumBy (fun (_, snapshot) -> snapshot.Bodies.Count)) then + invalidOp "Entity ownership is duplicated; simulation stopped." + blocked <- blocked |> Map.filter (fun entity _ -> Map.containsKey entity owners) + last <- { Tick = tick; Areas = areas; Ownership = owners; Traversals = List.rev traversals; Refused = List.rev refusals } + Advanced last + with error -> stop error.Message + + interface IDisposable with + member _.Dispose() = + if not disposed then + for KeyValue(_, world) in worlds do (world :> IDisposable).Dispose() + disposed <- true + +/// Portal-aware presentation snaps transferred entities and interpolates others normally. +[] +module AreaPhysics = + let interpolate alpha (previous: AreaSnapshot) (current: AreaSnapshot) = + if not (Double.IsFinite alpha) || alpha < 0. || alpha > 1. then invalidArg "alpha" "Alpha must be in [0,1]." + if current.Tick <> previous.Tick + 1L then invalidArg "current" "Snapshots must be consecutive." + let moved = current.Traversals |> List.map _.Entity |> Set.ofList + current.Areas |> Map.map (fun area snapshot -> + match Map.tryFind area previous.Areas with + | None -> snapshot.Bodies + | Some old -> + let ordinary = Physics.interpolate alpha old snapshot + snapshot.Bodies |> Map.map (fun entity pose -> + if Set.contains entity moved || Map.tryFind entity previous.Ownership <> Some area then pose + else ordinary[entity])) diff --git a/src/Game.Physics.Box2D/AreaRuntime.fsi b/src/Game.Physics.Box2D/AreaRuntime.fsi new file mode 100644 index 00000000..29a12af1 --- /dev/null +++ b/src/Game.Physics.Box2D/AreaRuntime.fsi @@ -0,0 +1,57 @@ +namespace FS.GG.Game.Physics.Box2D + +open System +open FS.GG.Game.Core +open FS.GG.Game.Physics.Box2D + +/// An independent active local world with a whole-body capacity, including static/sensor bodies. +type AreaSettings = { Area: AreaTopology.AreaId; Physics: Settings; Capacity: int } +/// A game command routed to exactly one active local world. +type AreaCommand = { Area: AreaTopology.AreaId; Command: Command } +/// Copied whole-body traversal, published only after source removal and ownership commit. +type Traversal = + { Tick: int64; Portal: AreaTopology.PortalId; Entity: string + Source: AreaTopology.AreaId; Destination: AreaTopology.AreaId + SourcePose: Pose; DestinationPose: Pose } +/// A known refusal preserves source ownership; uncertain engine mutation stops the whole owner. +type TransferRefusal = + | MissingDestination | CapacityExceeded | DestinationBlocked | JointConnected | StagingRejected of reason: string +/// A refused proposal for one entity during one tick. +type RefusedTraversal = { Entity: string; Portal: AreaTopology.PortalId; Reason: TransferRefusal } +/// Immutable copied observations after all local steps and transfers. +type AreaSnapshot = + { Tick: int64; Areas: Map + Ownership: Map; Traversals: Traversal list; Refused: RefusedTraversal list } +/// Explicit failure disposition. A stopped result is never successful acceptance and may contain +/// staged duplicates; LastKnown is the last fully published immutable state, not a rollback promise. +type AreaStepResult = Advanced of AreaSnapshot | Stopped of reason: string * lastKnown: AreaSnapshot + +/// Owns worlds on one simulation owner. Circle/box fixtures retain scale, density, material, +/// kind and sensor setting; current pose, velocities and engine awake state are preserved. +/// Filters, damping, gravity scale and sleep settings use the existing Runtime fixed defaults. +/// Destination placement conservatively refuses overlap of enclosing discs (sensors excluded). +/// Joint-connected bodies refuse. Self-area proposals refuse duplicate staging in this first slice. +/// All worlds step once before collection/transfer; destination contacts begin on its next tick. +/// Engine uncertainty stops this owner and retains its worlds; dispose explicitly after diagnosis. +[] +type AreaRuntime = + new: graph: AreaTopology.Graph * settings: AreaSettings list * hysteresis: float -> AreaRuntime + /// Last fully published immutable observation; unavailable after stop or disposal. + member Snapshot: unit -> AreaSnapshot + /// Best-effort copied actual state after stopping, without stepping. None retains an unreadable + /// world as unknown; no snapshot here certifies ownership or recovery. + member InspectStopped: unit -> Map + member internal CommitFault: (unit -> unit) option with set + /// Validate every routed batch first, step once per active area, propose once per entity, + /// then stage/commit without stepping. Ordinary refusals preserve source ownership. + member Step: commands: AreaCommand list -> AreaStepResult + member internal SetAwake: area: AreaTopology.AreaId * entity: string * awake: bool -> unit + member internal BodyState: area: AreaTopology.AreaId * entity: string -> BodyDescriptor * bool * bool + member internal LocalTicks: Map + interface IDisposable + +/// Portal-aware presentation snaps traversals and interpolates other entities normally. +[] +module AreaPhysics = + /// Validate alpha and consecutive ticks; return current-area pose maps. + val interpolate: alpha: float -> previous: AreaSnapshot -> current: AreaSnapshot -> Map> diff --git a/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj b/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj index a2c301c4..c48d06de 100644 --- a/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj +++ b/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj @@ -13,8 +13,11 @@ + + + diff --git a/src/Game.Physics.Box2D/Runtime.fs b/src/Game.Physics.Box2D/Runtime.fs index 8bb864fc..f2792fca 100644 --- a/src/Game.Physics.Box2D/Runtime.fs +++ b/src/Game.Physics.Box2D/Runtime.fs @@ -124,23 +124,30 @@ type Runtime(settings: Settings) = bodyDef.linearVelocity <- vec descriptor.LinearVelocity bodyDef.angularVelocity <- float32 descriptor.AngularVelocity let id = B2Bodies.b2CreateBody(world, &bodyDef) - let mutable shapeDef = B2Types.b2DefaultShapeDef() - shapeDef.density <- float32 descriptor.Density - shapeDef.material.friction <- float32 descriptor.Friction - shapeDef.material.restitution <- float32 descriptor.Restitution - shapeDef.isSensor <- descriptor.IsSensor - shapeDef.enableSensorEvents <- true - shapeDef.enableContactEvents <- true - let shape = - match descriptor.Shape with - | Circle radius -> - let geometry = B2Circle(B2Vec2(0.f, 0.f), float32 radius) - B2Shapes.b2CreateCircleShape(id, &shapeDef, &geometry) - | Box (width, height) -> - let geometry = B2Geometries.b2MakeBox(float32 width, float32 height) - B2Shapes.b2CreatePolygonShape(id, &shapeDef, &geometry) - bodies <- Map.add descriptor.Entity (id, shape, descriptor) bodies - shapes <- Map.add (key shape) descriptor.Entity shapes + try + let mutable shapeDef = B2Types.b2DefaultShapeDef() + shapeDef.density <- float32 descriptor.Density + shapeDef.material.friction <- float32 descriptor.Friction + shapeDef.material.restitution <- float32 descriptor.Restitution + shapeDef.isSensor <- descriptor.IsSensor + shapeDef.enableSensorEvents <- true + shapeDef.enableContactEvents <- true + let shape = + match descriptor.Shape with + | Circle radius -> + let geometry = B2Circle(B2Vec2(0.f, 0.f), float32 radius) + B2Shapes.b2CreateCircleShape(id, &shapeDef, &geometry) + | Box (width, height) -> + let geometry = B2Geometries.b2MakeBox(float32 width, float32 height) + B2Shapes.b2CreatePolygonShape(id, &shapeDef, &geometry) + bodies <- Map.add descriptor.Entity (id, shape, descriptor) bodies + shapes <- Map.add (key shape) descriptor.Entity shapes + with _ -> + // Retain the source during staging; clean this known partial allocation before propagating. + B2Bodies.b2DestroyBody id + bodies <- Map.remove descriptor.Entity bodies + shapes <- Map.filter (fun _ entity -> entity <> descriptor.Entity) shapes + reraise () | RemoveBody entity -> let (id, _, _) = bodies[entity] B2Bodies.b2DestroyBody id @@ -189,6 +196,39 @@ type Runtime(settings: Settings) = let e = contact.endEvents[i] yield ContactEnded(pair e.shapeIdA e.shapeIdB) ] |> List.sort + member internal _.ValidateBatch(commands: FS.GG.Game.Physics.Box2D.Command list) = ensureActive (); validate commands + member internal _.IsStopped = stopped + member internal _.Inspect() = if disposed then None else try Some (capture []) with _ -> None + member internal _.Stop() = stopped <- true + member internal _.Describe(entity) = + ensureActive () + let (id, _, descriptor) = bodies[entity] + let pose = (capture []).Bodies[entity] + let current = { descriptor with Position = pose.Position; Rotation = pose.Rotation + LinearVelocity = pose.LinearVelocity; AngularVelocity = pose.AngularVelocity } + current, B2Bodies.b2Body_IsAwake id, + (joints |> Map.exists (fun _ (_, joint) -> joint.EntityA = entity || joint.EntityB = entity)) + member internal _.Stage(descriptor, awake) = + ensureActive () + validate [CreateBody descriptor] + try + apply (CreateBody descriptor) + let (id, _, _) = bodies[descriptor.Entity] + // Staging must not accumulate b2MakeRot's approximate sine error on every traversal. + let rotation = B2Rot(float32 (cos descriptor.Rotation), float32 (sin descriptor.Rotation)) + B2Bodies.b2Body_SetTransform(id, vec descriptor.Position, rotation) + B2Bodies.b2Body_SetAwake(id, awake) + with _ -> stopped <- true; reraise () + member internal _.RemoveWithoutStep(entity) = + ensureActive () + validate [RemoveBody entity] + try apply (RemoveBody entity) + with _ -> stopped <- true; reraise () + member internal _.SetAwake(entity, awake) = + ensureActive () + let (id, _, _) = bodies[entity] + B2Bodies.b2Body_SetAwake(id, awake) + member _.Settings = settings member _.Snapshot() = ensureActive (); capture [] member _.Step(commands) = diff --git a/src/Game.Physics.Box2D/Runtime.fsi b/src/Game.Physics.Box2D/Runtime.fsi index 4701648c..0ff5dd02 100644 --- a/src/Game.Physics.Box2D/Runtime.fsi +++ b/src/Game.Physics.Box2D/Runtime.fsi @@ -10,6 +10,14 @@ type Runtime = new: settings: Settings -> Runtime /// The pinned fixed solver profile. member Settings: Settings + member internal ValidateBatch: commands: Command list -> unit + member internal IsStopped: bool + member internal Inspect: unit -> Snapshot option + member internal Stop: unit -> unit + member internal Describe: entity: string -> BodyDescriptor * bool * bool + member internal Stage: descriptor: BodyDescriptor * awake: bool -> unit + member internal RemoveWithoutStep: entity: string -> unit + member internal SetAwake: entity: string * awake: bool -> unit /// Copy the current poses without stepping; events are empty. member Snapshot: unit -> Snapshot /// Validate the full command batch, apply in order, step once and copy all observations. diff --git a/tests/Game.Physics.Box2D.Tests/AreaRuntimeTests.fs b/tests/Game.Physics.Box2D.Tests/AreaRuntimeTests.fs new file mode 100644 index 00000000..5d6c4934 --- /dev/null +++ b/tests/Game.Physics.Box2D.Tests/AreaRuntimeTests.fs @@ -0,0 +1,133 @@ +module Game.Physics.Box2D.Tests.AreaRuntimeTests + +open System +open Expecto +open FS.GG.Game.Core +open FS.GG.Game.Physics.Box2D + +module Scene = Box2D.Portals.Scene +let private p x y : Point = { X = x; Y = y } +let private advance = Scene.advance +let private command area cmd : AreaCommand = { Area = area; Command = cmd } +let private configuration = Scene.settings |> List.map (fun s -> { s with Physics = { s.Physics with Gravity = p 0. 0. } }) +let private get = function Ok value -> value | Error reason -> failwith reason +let private checkRefusal reason (snapshot: AreaSnapshot) = + Expect.equal snapshot.Refused.Head.Reason reason "declared refusal" + Expect.equal snapshot.Ownership.[Scene.moving.Entity] Scene.areaA "source ownership retained" + Expect.isEmpty snapshot.Traversals "no false success" +let private near a b = Expect.isLessThan (abs (a - b)) 2e-5 "single precision conversion tolerance" + +[] +let tests = testList "Box2D AreaRuntime" [ + test "real rotated two-area scene preserves identity momentum once-step and exact replay" { + let observed = Scene.qualify () + Expect.equal observed.Length 5 "actual headless scene" + } + test "whole supported descriptor and awake state survive non-stepping stage" { + use source = new Runtime(configuration.Head.Physics) + use destination = new Runtime(configuration.Head.Physics) + let body = { Scene.moving with LinearVelocity = p 0. 0.; AngularVelocity = 0.; Shape = Box(0.2, 0.3) } + source.Step [CreateBody body] |> ignore + source.SetAwake(body.Entity, false) + let descriptor, awake, jointed = source.Describe body.Entity + Expect.isFalse awake "engine source is asleep" + Expect.isFalse jointed "unjointed" + destination.Stage(descriptor, awake) + source.RemoveWithoutStep body.Entity + let actual, actualAwake, _ = destination.Describe body.Entity + Expect.equal { actual with Rotation = descriptor.Rotation } descriptor "shape/material/kind/sensor/pose/momentum preserved" + near (Math.IEEERemainder(actual.Rotation - descriptor.Rotation, 2. * Math.PI)) 0. + Expect.equal actualAwake awake "engine sleeping state preserved" + Expect.equal (source.Snapshot().Tick, destination.Snapshot().Tick) (1L, 0L) "mutation never steps" + Expect.isEmpty (source.Snapshot().Bodies) "source gone after commit" + } + test "missing destination preserves source after its ordinary step" { + use runtime = new AreaRuntime(Scene.graph, [configuration.Head], 0.05) + let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving)] |> advance + checkRefusal MissingDestination snapshot + } + test "capacity failure preserves source and resident" { + let configs = configuration |> List.map (fun s -> if s.Area = Scene.areaB then { s with Capacity = 1 } else s) + use runtime = new AreaRuntime(Scene.graph, configs, 0.05) + let resident = { Physics.circle "resident" 0.1 (p 30. 30.) with Kind = Static } + let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving); command Scene.areaB (CreateBody resident)] |> advance + checkRefusal CapacityExceeded snapshot + Expect.equal snapshot.Ownership.Count 2 "both entities retained" + } + test "blocked destination placement preserves source" { + use runtime = new AreaRuntime(Scene.graph, configuration, 0.05) + let resident = { Physics.circle "resident" 0.5 (p 9.9 5.15) with Kind = Static } + let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving); command Scene.areaB (CreateBody resident)] |> advance + checkRefusal DestinationBlocked snapshot + } + test "joint-connected crossing refuses without implicitly breaking joint" { + use runtime = new AreaRuntime(Scene.graph, configuration, 0.05) + let anchor = { Physics.circle "anchor" 0.1 (p -0.25 -1.) with Kind = Static } + let body = { Scene.moving with LinearVelocity = p 10. 0.; AngularVelocity = 0. } + let snapshot = runtime.Step [command Scene.areaA (CreateBody anchor); command Scene.areaA (CreateBody body) + command Scene.areaA (CreateDistanceJoint { Joint = "joint"; EntityA = anchor.Entity; EntityB = body.Entity; Length = 1. })] |> advance + checkRefusal JointConnected snapshot + let _, _, jointed = runtime.BodyState(Scene.areaA, body.Entity) + Expect.isTrue jointed "joint remains attached" + } + test "actual staging descriptor precision refusal preserves source without destination allocation" { + let mapping = AreaTopology.rigidMap 0. (p 1e39 0.) |> get + let portal = { Scene.forward with Mapping = mapping; Reverse = None + DestinationOpening = { Start = p 1e39 -2.; Finish = p 1e39 2. } } + let graph = AreaTopology.create [Scene.areaA; Scene.areaB] [portal] |> get + use runtime = new AreaRuntime(graph, configuration, 0.05) + let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving)] |> advance + match snapshot.Refused.Head.Reason with StagingRejected _ -> () | reason -> failtestf "Expected staging refusal, got %A" reason + Expect.equal snapshot.Ownership.[Scene.moving.Entity] Scene.areaA "source retained" + Expect.isEmpty snapshot.Areas.[Scene.areaB].Bodies "no staged allocation" + } + test "injected commit failure cleans real staged destination and stops with actual observations" { + use runtime = new AreaRuntime(Scene.graph, configuration, 0.05) + let body = { Scene.moving with Position = p -0.65 0. } + runtime.Step [command Scene.areaA (CreateBody body)] |> advance |> ignore + runtime.CommitFault <- Some (fun () -> failwith "injected commit interruption") + match runtime.Step [] with + | Advanced _ -> failtest "Uncertain transaction must not report success." + | Stopped(reason, lastKnown) -> + Expect.stringContains reason "injected" "fault provenance" + Expect.equal lastKnown.Tick 1L "last fully published tick retained" + let actual = runtime.InspectStopped() + Expect.isSome actual.[Scene.areaA] "source actual state readable" + Expect.isTrue (actual.[Scene.areaA].Value.Bodies.ContainsKey body.Entity) "real source preserved" + Expect.isEmpty actual.[Scene.areaB].Value.Bodies "real staged destination removed" + Expect.throws (fun () -> runtime.Step [] |> ignore) "stopped owner refuses further simulation" + } + test "exit hysteresis blocks immediate bounce then re-arms after recorded clearance" { + use runtime = new AreaRuntime(Scene.graph, configuration, 0.25) + let first = runtime.Step [command Scene.areaA (CreateBody Scene.moving)] |> advance + Expect.equal first.Traversals.Length 1 "first crossing" + let bounced = runtime.Step [command Scene.areaB (ApplyImpulse(Scene.moving.Entity, p 0. (-8. * Math.PI * 0.1 * 0.1 * Scene.moving.Density)))] |> advance + Expect.isEmpty bounced.Traversals "immediate reverse crossing excluded" + // Restore positive Y velocity; first clear below, then travel above the aperture, then return. + runtime.Step [command Scene.areaB (ApplyImpulse(Scene.moving.Entity, p 0. (8. * Math.PI * 0.1 * 0.1 * Scene.moving.Density)))] |> advance |> ignore + for _ in 1 .. 3 do runtime.Step [] |> advance |> ignore + runtime.Step [command Scene.areaB (ApplyImpulse(Scene.moving.Entity, p 0. (-8. * Math.PI * 0.1 * 0.1 * Scene.moving.Density)))] |> advance |> ignore + let returned = [for _ in 1 .. 5 -> runtime.Step [] |> advance] |> List.collect _.Traversals + Expect.equal returned.Length 1 "one reverse crossing after clearance" + Expect.equal returned.Head.Destination Scene.areaA "back in original area" + } + test "traversal interpolation snaps moved body while ordinary entities blend" { + use runtime = new AreaRuntime(Scene.graph, configuration, 0.05) + let body = { Scene.moving with Position = p -0.65 0. } + let other = { Physics.circle "ordinary" 0.1 (p -10. 0.) with LinearVelocity = p 1. 0. } + let previous = runtime.Step [command Scene.areaA (CreateBody body); command Scene.areaA (CreateBody other)] |> advance + let current = runtime.Step [] |> advance + let displayed = AreaPhysics.interpolate 0.5 previous current + Expect.equal displayed.[Scene.areaB].[body.Entity] current.Areas.[Scene.areaB].Bodies.[body.Entity] "cross-area snap" + let oldX, newX = previous.Areas.[Scene.areaA].Bodies.[other.Entity].Position.X, current.Areas.[Scene.areaA].Bodies.[other.Entity].Position.X + near displayed.[Scene.areaA].[other.Entity].Position.X ((oldX + newX) / 2.) + let retained = current + runtime.Step [] |> advance |> ignore + Expect.equal current retained "retained immutable state" + } + test "global command validation refuses duplicate identity before any world steps" { + use runtime = new AreaRuntime(Scene.graph, configuration, 0.05) + Expect.throws (fun () -> runtime.Step [command Scene.areaA (CreateBody Scene.moving); command Scene.areaB (CreateBody Scene.moving)] |> ignore) "global identity refusal" + Expect.equal runtime.LocalTicks (Map.ofList [Scene.areaA, 0L; Scene.areaB, 0L]) "all worlds unstepped" + } +] diff --git a/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj b/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj index 1db11d1b..1a70ab11 100644 --- a/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj +++ b/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj @@ -2,6 +2,8 @@ Exefalsefalse + +