#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "climbing/ClimbingECS.h" #include "physics/Body3D.h" #include "physics/Physics.h" #include "physics/World3D.h" #include #include #include namespace { class TestClimber : public ecs::Entity { public: ENTITY(TestClimber, ecs::Entity) void release() override { ecs::DestroyEntity(this); } COMPONENT(eve::climbing::ClimbingBody, climbingBody) COMPONENT(eve::climbing::ClimbingIntent, climbingIntent) COMPONENT(eve::climbing::ClimbingState, climbingState) COMPONENT(eve::climbing::ClimbingLinks, climbingLinks) COMPONENT(eve::climbing::ClimbingCandidateBuffer, climbingCandidates) COMPONENT(eve::climbing::ClimbingPoseProjection, climbingPose) COMPONENT(eve::climbing::ClimbingEventBatch, climbingEvents) }; eve::climbing::ClimbingActionDefinition vaultAction() { eve::climbing::ClimbingActionDefinition action; action.id = "parkour:vault_low"; action.kind = eve::climbing::ClimbingActionKind::Vault; action.minHeight = 0.4f; action.maxHeight = 1.2f; action.duration = eve::Duration::fromNanoseconds(600000000); action.landingForward = 0.7f; action.apexHeight = 0.7f; return action; } struct EcsClimbingFixture { EcsClimbingFixture() { world.reset(eve::physics::Physics::create()->newWorld3D(0.f, 0.f, 0.f, false)); character = world->newBody("kinematic", 0.f, 0.9f, 0.f); character->newCapsuleShape(1.8f, 0.3f); obstacle = world->newBody("static", 0.f, 0.5f, 1.f); obstacle->newBoxShape(2.f, 1.f, 0.5f); } std::unique_ptr world; eve::physics::Body3D* character = nullptr; eve::physics::Body3D* obstacle = nullptr; }; } // namespace TEST_CASE("climbing.ecs.composesWithExistingRootAcrossFixedPhases") { ecs::Table table; ecs::ScopedTable tableScope(table); EcsClimbingFixture fixture; TestClimber* entity = TestClimber::create(); auto state = eve::climbing::ClimbingState::create(); REQUIRE(state.ok()); *entity->climbingState() = std::move(state).takeValue(); auto bodyLink = eve::physics::PhysicsLink::fromBody(*fixture.character); REQUIRE(bodyLink.ok()); entity->climbingLinks()->physicsBody = std::move(bodyLink).takeValue(); auto runtime = eve::climbing::Climbing::resolve(entity->climbingState()->runtime); REQUIRE(runtime.isBound()); REQUIRE(runtime->upsertAction(vaultAction()).ok()); auto body = entity->climbingBody(); body->feet = {0.f, 0.f, 0.f}; body->lastStableFeet = body->feet; body->forward = {0.f, 0.f, 1.f}; body->velocity = {0.f, 0.f, 1.f}; body->grounded = true; body->lastGroundedTick = eve::SimulationTick(10); REQUIRE(eve::climbing::ClimbingInputSystem::submit(*entity->climbingIntent(), eve::climbing::ClimbingCommand::Climb, eve::SimulationTick(10), 4) .ok()); auto probed = eve::climbing::ClimbingProbeSystem::step(*fixture.world, eve::SimulationTick(10)); REQUIRE(probed.ok()); CHECK_EQ(probed.value(), std::size_t{1}); CHECK(!entity->climbingCandidates()->values().empty()); CHECK(entity->climbingCandidates()->tick() == eve::SimulationTick(10)); auto selected = eve::climbing::ClimbingEcsSelectionSystem::step( *fixture.world, eve::climbing::ClimbingCommand::Climb, eve::SimulationTick(10)); REQUIRE(selected.ok()); CHECK_EQ(selected.value(), std::size_t{1}); CHECK(!runtime->executionId().isZero()); CHECK(!entity->climbingIntent()->commands.front().consumedExecutionId.isZero()); const eve::SimulationStep step{eve::SimulationTick(11), eve::Duration::fromNanoseconds(16666667)}; auto advanced = eve::climbing::ClimbingMotionSystem::step(*fixture.world, step); REQUIRE(advanced.ok()); CHECK_EQ(advanced.value(), std::size_t{1}); CHECK(entity->climbingState()->lastAdvanceTick == step.tick); CHECK(std::fabs(fixture.character->getY() - (body->feet.y + body->capsuleHeight * 0.5f)) < 1e-4f); auto posed = eve::climbing::ClimbingPoseSystem::step(step.tick); REQUIRE(posed.ok()); CHECK_EQ(posed.value(), std::size_t{1}); CHECK(entity->climbingPose()->executionId == runtime->executionId()); REQUIRE(!entity->climbingEvents()->values().empty()); CHECK_EQ(static_cast(entity->climbingEvents()->values().front().kind), static_cast(eve::climbing::ClimbingEventKind::Started)); REQUIRE(entity->climbingState()->releaseRuntime().ok()); CHECK(!eve::climbing::Climbing::resolve(entity->climbingState()->runtime).isBound()); entity->release(); } TEST_CASE("climbing.ecs.rejectsStalePhysicsLinkWithoutMutatingDerivedCandidates") { ecs::Table table; ecs::ScopedTable tableScope(table); EcsClimbingFixture fixture; TestClimber* entity = TestClimber::create(); auto state = eve::climbing::ClimbingState::create(); REQUIRE(state.ok()); *entity->climbingState() = std::move(state).takeValue(); auto runtime = eve::climbing::Climbing::resolve(entity->climbingState()->runtime); REQUIRE(runtime.isBound()); REQUIRE(runtime->upsertAction(vaultAction()).ok()); auto link = eve::physics::PhysicsLink::fromBody(*fixture.character); REQUIRE(link.ok()); entity->climbingLinks()->physicsBody = std::move(link).takeValue(); eve::climbing::ClimbingCandidate sentinel; sentinel.actionId = "test:sentinel"; const std::vector oldValues{sentinel}; REQUIRE(entity->climbingCandidates()->replace(oldValues, eve::SimulationTick(7)).ok()); fixture.world->destroyBody(fixture.character); fixture.character = nullptr; auto probed = eve::climbing::ClimbingProbeSystem::step(*fixture.world, eve::SimulationTick(8)); CHECK(!probed.ok()); CHECK_EQ(entity->climbingCandidates()->values().size(), std::size_t{1}); CHECK_EQ(entity->climbingCandidates()->values().front().actionId, std::string("test:sentinel")); CHECK(entity->climbingCandidates()->tick() == eve::SimulationTick(7)); REQUIRE(entity->climbingState()->releaseRuntime().ok()); entity->release(); } TEST_CASE("climbing.ecs.profileDrivesCollisionConstrainedGroundAndAirLocomotion") { ecs::Table table; ecs::ScopedTable tableScope(table); EcsClimbingFixture fixture; auto* floor = fixture.world->newBody("static", 0.f, -0.1f, 0.f); floor->newBoxShape(20.f, 0.2f, 20.f); TestClimber* entity = TestClimber::create(); auto state = eve::climbing::ClimbingState::create(); REQUIRE(state.ok()); *entity->climbingState() = std::move(state).takeValue(); auto link = eve::physics::PhysicsLink::fromBody(*fixture.character); REQUIRE(link.ok()); entity->climbingLinks()->physicsBody = std::move(link).takeValue(); auto runtime = eve::climbing::Climbing::resolve(entity->climbingState()->runtime); REQUIRE(runtime.isBound()); eve::climbing::ClimbingProfileDefinition profile; profile.groundAcceleration = 12.f; profile.groundBraking = 24.f; profile.airControl = 0.25f; profile.gravity = 18.f; profile.jumpSpeed = 6.f; REQUIRE(runtime->setProfile(profile).ok()); auto body = entity->climbingBody(); body->feet = {0.f, 0.f, -2.f}; body->lastStableFeet = body->feet; body->lastGroundedTick = eve::SimulationTick(1); entity->climbingIntent()->move = {3.f, 0.f, 0.f}; const eve::Duration fixedDelta = eve::Duration::fromNanoseconds(16666667); auto grounded = eve::climbing::ClimbingMotionSystem::step(*fixture.world, {eve::SimulationTick(2), fixedDelta}); REQUIRE(grounded.ok()); CHECK_EQ(grounded.value(), std::size_t{1}); CHECK(body->feet.x > 0.f); CHECK(body->velocity.x > 0.f); CHECK(body->velocity.x < 3.f); CHECK(body->grounded); CHECK_EQ(runtime->counters().queryCount, std::uint32_t{1}); CHECK_EQ(static_cast(runtime->counters().workload), static_cast(eve::climbing::ClimbingWorkload::Ordinary)); REQUIRE(eve::climbing::ClimbingInputSystem::submit(*entity->climbingIntent(), eve::climbing::ClimbingCommand::Jump, eve::SimulationTick(3), profile) .ok()); auto jumped = eve::climbing::ClimbingMotionSystem::step(*fixture.world, {eve::SimulationTick(3), fixedDelta}); REQUIRE(jumped.ok()); CHECK(!body->grounded); CHECK(body->velocity.y > 0.f); const float launchedVelocity = body->velocity.y; auto airborne = eve::climbing::ClimbingMotionSystem::step(*fixture.world, {eve::SimulationTick(4), fixedDelta}); REQUIRE(airborne.ok()); CHECK(body->velocity.y < launchedVelocity); const std::uint64_t lastJumpTick = body->lastOrdinaryJumpPressedTick.value(); CHECK_EQ(lastJumpTick, std::uint64_t{3}); REQUIRE(entity->climbingState()->releaseRuntime().ok()); entity->release(); } TEST_CASE("climbing.ecs.stepHeightUsesBoundedOwningMoverQueries") { ecs::Table table; ecs::ScopedTable tableScope(table); EcsClimbingFixture fixture; auto* floor = fixture.world->newBody("static", 0.f, -0.1f, -2.f); floor->newBoxShape(8.f, 0.2f, 4.f); auto* stepBody = fixture.world->newBody("static", 0.f, 0.1f, -2.f); stepBody->newBoxShape(0.5f, 0.2f, 2.f); TestClimber* entity = TestClimber::create(); auto state = eve::climbing::ClimbingState::create(); REQUIRE(state.ok()); *entity->climbingState() = std::move(state).takeValue(); auto link = eve::physics::PhysicsLink::fromBody(*fixture.character); REQUIRE(link.ok()); entity->climbingLinks()->physicsBody = std::move(link).takeValue(); auto runtime = eve::climbing::Climbing::resolve(entity->climbingState()->runtime); REQUIRE(runtime.isBound()); auto body = entity->climbingBody(); auto reset = [&] { body->feet = {-0.8f, 0.f, -2.f}; body->velocity = {2.f, 0.f, 0.f}; body->grounded = true; body->lastGroundedTick = eve::SimulationTick(10); fixture.character->setPosition(-0.8f, body->capsuleHeight * 0.5f, -2.f); }; entity->climbingIntent()->move = {2.f, 0.f, 0.f}; const eve::Duration halfSecond = eve::Duration::fromNanoseconds(500000000); reset(); body->stepHeight = 0.f; REQUIRE( eve::climbing::ClimbingMotionSystem::step(*fixture.world, {eve::SimulationTick(11), halfSecond}) .ok()); const float blockedX = body->feet.x; reset(); body->stepHeight = 0.35f; REQUIRE( eve::climbing::ClimbingMotionSystem::step(*fixture.world, {eve::SimulationTick(12), halfSecond}) .ok()); CHECK(body->feet.x > blockedX + 0.2f); CHECK(body->feet.y > 0.15f); CHECK(body->grounded); CHECK_EQ(runtime->counters().queryCount, eve::climbing::ClimbingQueryBudgets::Ordinary); CHECK(runtime->counters().moverIterations > 0); const auto samples = runtime->telemetry().samples(); REQUIRE(!samples.empty()); CHECK(samples.back().elapsedNanoseconds > 0); REQUIRE(entity->climbingState()->releaseRuntime().ok()); entity->release(); } TEST_CASE("climbing.ecs.fixedBuffersAreTransactionalAndContractsAreComplete") { eve::climbing::ClimbingCandidateBuffer candidates; eve::climbing::ClimbingCandidate sentinel; sentinel.actionId = "test:sentinel"; const std::vector initial{sentinel}; REQUIRE(candidates.replace(initial, eve::SimulationTick(3)).ok()); std::vector overflow(eve::climbing::ClimbingCandidateBuffer::Capacity + 1); auto rejected = candidates.replace(overflow, eve::SimulationTick(4)); CHECK(!rejected.ok()); CHECK_EQ(candidates.values().size(), std::size_t{1}); CHECK_EQ(candidates.values().front().actionId, std::string("test:sentinel")); CHECK(candidates.tick() == eve::SimulationTick(3)); const auto contracts = eve::climbing::climbingSystemContracts(); CHECK_EQ(contracts.size(), std::size_t{5}); for (const auto& contract : contracts) { CHECK(!contract.name.empty()); CHECK(!contract.entityScope.empty()); CHECK(!contract.view.empty()); CHECK(!contract.readSet.empty()); CHECK(!contract.writeSet.empty()); CHECK(!contract.phase.empty()); CHECK(!contract.determinism.empty()); } }