e2e/

directory
v0.18.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: LGPL-3.0

README

physics2d end-to-end suite

Headless Cardinal worlds whose only job is to exercise pkg/plugin/physics2d and fail on anything that does not behave the way Box2D documents. No NATS, no Redis, no Docker; moon run world-engine:test runs it with the rest of ./pkg/....

go test ./pkg/plugin/physics2d/test/e2e/                            # everything
go test ./pkg/plugin/physics2d/test/e2e/ -run TestScenarios/flags -v # one scenario, passing checks included
go run  ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -h         # the same suite as a CLI

What runs

Test Pins
TestScenarios Thirteen scripted scenarios, about 400 checks. Each runs in its own world as a parallel subtest, so -run TestScenarios/flags works and a failure names the scenario and the line
TestExhaustiveBodyMatrix Every body type × every shape type × every combination of the five body flags (896 bodies, one world). Each flag is read back from the engine, not the component; the wire round-trip is lossless; a Plugin.Reset rebuild recreates the same engine state
TestRandomScenes Seeded random scenes of valid bodies over a static floor. Invariants: lossless wire round-trip, no solid dynamic body ends up inside the floor, and the same seed simulates identically twice and at Workers: 4
TestRestore Snapshot a world, round-trip every component through the wire format, rebuild it in a fresh world, simulate both on and compare. Once with the documented Plugin.Reset, once without
TestDigestIsWorkerInvariant All scenarios together in one world, twice in the same configuration, then at Workers: 4. All three state hashes must match
TestHostile Each crash-prone shape, alone in a child process

Every check reports through the subtest's testing.T, attributed to the scenario's own line; the harness's printed report is what the CLI shows instead.

TestDigestIsWorkerInvariant covers two different promises. Same configuration twice is the only check that catches a Go map iterated for its side effects; no worker-count or golden-file comparison can. Different worker counts pins the engine's promise that Config.Workers is a throughput knob and nothing else.

Generated coverage

TestExhaustiveBodyMatrix is the enumerated form of the question that started this suite: does a Go zero value leak anywhere Box2D defaults to true? testutils.Gen walks every combination, so nothing is left to a hand-picked sample. It found one divergence, pinned in knownDivergence: the plugin accepts chains and edges on dynamic bodies although the component docs say static or kinematic only. Enforcing the rule flips that check, which is the signal to remove the entry.

TestRandomScenes is the physics analogue of pkg/box2d's op-sequence fuzz. A scene is a pure function of the seed in its subtest name; reproduce one with

PHYSICS2D_E2E_SCENE_SEED=<hex from the subtest name> go test ./pkg/plugin/physics2d/test/e2e/ -run TestRandomScenes -v

Its generator respects the documented static/kinematic-only rule for edges and chains because the consequence of breaking it is not subtle: a dynamic body carrying an edge falls straight through a 10 m static floor. Kinematic bodies collide with nothing there, because one moving downward can legitimately squeeze a dynamic body through the floor, and that would make the invariant unprovable.

Scenarios
Scenario Pins
defaults Constructor and wire defaults, the zero-value trap, Validate
shapes All 7 ShapeTypes reach Box2D and collide by their geometry
bodytypes Static / dynamic / kinematic / manual, and the writeback rules
flags Active, Awake, SleepingAllowed, Bullet, FixedRotation, gravity scale, damping; teleport with an explicit same-tick sleep
material Friction mixes as sqrt(a*b), restitution as max(a,b), density becomes mass
filtering Category/mask, one-sided masks, group index, full 64-bit filter width
sensors Triggers vs contacts, compound slots, sleeping bodies, static visitors, disabled bodies
contacts Event entities, shape slots, world-space normal and point, filters, Begin/End lifecycle
compound Child offsets and rotations, slot identity, combined centre of mass
queries Raycast, OverlapAABB (narrow-phase), CircleSweep, plus every documented edge case
lifecycle Create, destroy, teleport, retype, resize (capsules included), add/remove shapes, refilter, retune
stability 10-box stack, deep overlap recovery, 2 cm to 100 m shapes, 5 km from origin
reset Plugin.Reset rebuild: poses, velocities, no replayed events, queries

Two watchdogs run every tick regardless of scenario: every body is checked for NaN/Inf and named if it goes bad, and the engine world is checked for disappearing without a scenario asking for it.

Behaviour worth knowing, all pinned by passing checks:

  • The engine clamps linear speed to 400 m/s; the plugin exposes no way to raise it.
  • The tick that rebuilds after Plugin.Reset reconciles but does not step, so one tick of simulated time is lost.
  • Transform2D.Rotation is wrapped to [-π, π] on writeback.
  • A spinning body whose centre of mass is off its origin gets linear velocity even with Velocity2D.Linear zero. That is Box2D, not a bug.
  • NewPhysicsBody2D lives in physics2d/component; the plugin root does not re-export it.
Crash-prone cases

TestHostile runs each case from scenario.HostileNames() in its own process, because a shape that panics inside a tick would otherwise take every later test with it. The child is TestHostileChild, driven by PHYSICS2D_E2E_HOSTILE_CASE. The test binary's exit code is the verdict: 0 passed, 1 a check failed, 2 the process panicked (reported as FATAL).

knownFailures in e2e_test.go lists the cases that fail today and why. A case that starts passing fails the test until it is removed from that map, so an engine change that fixes one is noticed rather than absorbed. Today that is only zero-extent-box: ColliderShape.Validate accepts zero half-extents, the engine builds a (NaN, NaN) body from them where C's assert would have fired, and the reconciler then rejects the entity every tick for as long as it lives.

The rest reject cleanly or simulate: destroy-during-contact, short-chain, short-chain-loop, zero-radius-circle, negative-radius-circle, polygon-no-vertices, polygon-two-vertices, degenerate-capsule, chain-on-dynamic-body. Note that a rejected shape retries forever: the entity stays in ECS with no body and ReconcileFromECS logs the same failure every tick.

How it works

  • Each scenario owns a lane, 300 m of world to itself. Scenario code is written in lane-local coordinates and the harness offsets them, so nothing in one scenario can collide with, or be found by a query from, another.
  • A scenario has three hooks. Setup runs on cardinal.Init, before the plugin, so the first FullRebuildFromECS sees the bodies. EachTick runs on PreUpdate, before the physics pipeline; drive gameplay-owned bodies there. Steps run on Update, after the pipeline, where post-step positions and this tick's contact events are both visible.
  • harness.InitECS reaches the unexported ecs.World.Init by reflection, and the restore driver reaches ToProto/FromProto the same way. cardinal.World only calls these inside StartGame, which needs NATS. It is the same shim the plugin's own integration tests use.

Adding a check

func MyThing() harness.Scenario {
	var s struct{ ball cardinal.EntityID }
	return harness.Scenario{
		Name: "mything",
		Setup: func(c *harness.Ctx) {
			s.ball = c.Spawn("ball", 0, 10, body(physics.BodyTypeDynamic, circle(0.5)))
		},
		Steps: []harness.Step{
			{Tick: 120, Do: func(c *harness.Ctx) {
				c.Near("the ball lands", c.Pos(s.ball).Y, 0.5, 0.15)
			}},
		},
	}
}

Add it to scenario.All(); it gets the next lane automatically.

Assert an observable consequence, never the component you just wrote: the component is exactly where a flag that never reached Box2D still looks correct. Use c.Note(...) for a measurement worth reading but not worth a hard threshold; notes never fail the run.

The crash-restore check

This is a different path from the reset scenario. reset calls Plugin.Reset on a live world and rebuilds from ECS still in memory. A restore round-trips every component through MarshalWire/UnmarshalWire first, which for PhysicsBody2D means its custom decoder, the code that decides whether an absent field means false or Box2D's default of true.

It reproduces Cardinal's real ordering: the game's Init systems spawn a whole scene and the plugin builds a Box2D world from it, and only then does the restore call FromProto and throw that ECS state away. The physics world is left describing a scene that no longer exists.

The scene is built so every body carries at least one value that differs from the default it would fall back to: no body has all three sleep flags true, none is left at GravityScale 1 alone, filters use bits above 32 and both signs of group index, and all seven shape types are present. A dropped or defaulted field shows up as that field reverting, and the comparison names it.

The CLI

go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e             # the suite
go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -v          # ...with passing checks
go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -digest     # ...plus a state hash to diff between runs
go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -workers 8  # engine worker count; the digest must not change
go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -restore [-restore-no-reset]
go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -hostile list
go run ./pkg/plugin/physics2d/test/e2e/cmd/physics2d-e2e -serve      # run as a real Cardinal shard (needs NATS)

Layout

e2e_test.go                 scripted scenarios, restore, digest, hostile cases
exhaustive_matrix_test.go   every body/shape/flag combination
random_scenes_test.go       seeded random scenes and their invariants
cmd/physics2d-e2e/          the CLI
internal/harness/      lanes, the scenario API, the report, snapshot helpers, the runner
internal/scenario/     the scenarios, shape builders, the hostile cases
internal/restore/      the two-world crash-restore driver
internal/probe/        the Probe component that names every spawned body

The tree has a scoped exclusion in .golangci.yaml for forbidigo, funlen and goprintffuncname; the reasons are in the comment there. Every correctness linter still applies.

Directories

Path Synopsis
cmd
physics2d-e2e command
Command game is the physics2d test game: a Cardinal world whose only job is to exercise the physics2d plugin and report anything that does not behave the way Box2D does.
Command game is the physics2d test game: a Cardinal world whose only job is to exercise the physics2d plugin and report anything that does not behave the way Box2D does.
internal
probe
Package probe holds the ECS components this test game adds on top of the physics2d plugin's own Transform2D / Velocity2D / PhysicsBody2D.
Package probe holds the ECS components this test game adds on top of the physics2d plugin's own Transform2D / Velocity2D / PhysicsBody2D.
restore
Package restore drives the crash-restore check: snapshot a running world, rebuild it in a fresh one, and compare.
Package restore drives the crash-restore check: snapshot a running world, rebuild it in a fresh one, and compare.
scenario
Package scenario holds the individual physics2d test scenarios.
Package scenario holds the individual physics2d test scenarios.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL