521 lines
25 KiB
C++
521 lines
25 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "animation/AnimClip.h"
|
|
#include "climbing/Climbing.h"
|
|
#include "physics/Body3D.h"
|
|
#include "physics/Physics.h"
|
|
#include "physics/Shape3D.h"
|
|
#include "physics/World3D.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <memory>
|
|
|
|
#if defined(_MSC_VER)
|
|
#include <crtdbg.h>
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
#if defined(_MSC_VER)
|
|
std::size_t trackedHeapAllocations = 0;
|
|
bool trackHeapAllocations = false;
|
|
|
|
int __cdecl climbingAllocationHook(int allocationType, void*, std::size_t, int, long, const unsigned char*, int) {
|
|
if (trackHeapAllocations && allocationType == _HOOK_ALLOC) ++trackedHeapAllocations;
|
|
return 1;
|
|
}
|
|
#endif
|
|
|
|
eve::Duration seconds(double value) {
|
|
auto duration = eve::Duration::fromSeconds(value);
|
|
REQUIRE(duration.ok());
|
|
return std::move(duration).takeValue();
|
|
}
|
|
|
|
eve::climbing::ClimbingActionDefinition mantleAction(std::string id = "core:mantle") {
|
|
return {std::move(id), 0.4f, 1.2f, 0.f, seconds(0.6), 0.6f, 0.7f, 0};
|
|
}
|
|
|
|
eve::climbing::ClimbingActionDefinition ledgeGrabAction() {
|
|
auto action = mantleAction("core:ledge_grab");
|
|
action.kind = eve::climbing::ClimbingActionKind::LedgeGrab;
|
|
action.apexHeight = 0.1f;
|
|
return action;
|
|
}
|
|
|
|
struct ClimbingWorld {
|
|
ClimbingWorld() {
|
|
world.reset(eve::physics::Physics::create()->newWorld3D(0.f, 0.f, 0.f, false));
|
|
obstacle = world->newBody("static", 0.f, 0.5f, 1.f);
|
|
shape = obstacle->newBoxShape(2.f, 1.f, 0.5f);
|
|
}
|
|
|
|
std::unique_ptr<eve::physics::World3D> world;
|
|
eve::physics::Body3D* obstacle = nullptr;
|
|
eve::physics::Shape3D* shape = nullptr;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("climbing.profile.rejectsInvalidCapsuleAndActionDefinitions") {
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
eve::climbing::ClimbingProfile profile;
|
|
profile.capsuleRadius = 0.5f;
|
|
profile.capsuleHeight = 1.f;
|
|
auto invalidProfile = runtime.setProfile(profile);
|
|
CHECK(!invalidProfile.ok());
|
|
CHECK_EQ(static_cast<int>(invalidProfile.code()), static_cast<int>(eve::StatusCode::Rejected));
|
|
|
|
auto action = mantleAction();
|
|
action.duration = eve::Duration::zero();
|
|
auto invalidAction = runtime.upsertAction(std::move(action));
|
|
CHECK(!invalidAction.ok());
|
|
CHECK_EQ(static_cast<int>(invalidAction.code()), static_cast<int>(eve::StatusCode::Rejected));
|
|
}
|
|
|
|
TEST_CASE("climbing.probeReturnsOwningHandlesAndRestoresPhysicsQueryState") {
|
|
ClimbingWorld fixture;
|
|
fixture.world->setQueryFilter(7, 13);
|
|
fixture.world->setQueryIgnoredBodyId(99);
|
|
fixture.world->setQueryIgnoredShapeId(101);
|
|
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto added = runtime.upsertAction(mantleAction());
|
|
REQUIRE(added.ok());
|
|
auto candidates = runtime.probe(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1});
|
|
REQUIRE(candidates.ok());
|
|
REQUIRE_EQ(candidates.value().size(), std::size_t{1});
|
|
const auto& candidate = candidates.value().front();
|
|
CHECK_EQ(candidate.actionId, std::string("core:mantle"));
|
|
CHECK(candidate.world == fixture.world->runtimeHandle());
|
|
CHECK(candidate.obstacleBody == fixture.obstacle->runtimeHandle());
|
|
CHECK(candidate.obstacleShape == fixture.shape->runtimeHandle());
|
|
CHECK(std::fabs(candidate.obstacleHeight - 1.f) < 0.02f);
|
|
CHECK_EQ(fixture.world->getQueryCategoryBits(), 7);
|
|
CHECK_EQ(fixture.world->getQueryMaskBits(), 13);
|
|
CHECK_EQ(fixture.world->getQueryIgnoredBodyId(), 99);
|
|
CHECK_EQ(fixture.world->getQueryIgnoredShapeId(), 101);
|
|
}
|
|
|
|
TEST_CASE("climbing.selectionIsDeterministicAcrossDefinitionInsertionOrder") {
|
|
ClimbingWorld fixture;
|
|
auto select = [&](bool reverse) {
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto preferred = mantleAction("core:mantle");
|
|
auto fallback = mantleAction("core:vault");
|
|
fallback.selectionBias = 2;
|
|
auto first = runtime.upsertAction(reverse ? fallback : preferred);
|
|
REQUIRE(first.ok());
|
|
auto second = runtime.upsertAction(reverse ? preferred : fallback);
|
|
REQUIRE(second.ok());
|
|
auto candidates = runtime.probe(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1});
|
|
REQUIRE(candidates.ok());
|
|
REQUIRE_EQ(candidates.value().size(), std::size_t{2});
|
|
return candidates.value().front().actionId;
|
|
};
|
|
CHECK_EQ(select(false), std::string("core:mantle"));
|
|
CHECK_EQ(select(true), std::string("core:mantle"));
|
|
}
|
|
|
|
TEST_CASE("climbing.selectionProfileWeightsAndInputModeDriveQuantizedCost") {
|
|
ClimbingWorld fixture;
|
|
const auto scoreFor = [&](eve::climbing::ClimbingScoreWeights weights, eve::climbing::ClimbingInputMode mode,
|
|
eve::climbing::Vec3 forward = {0.f, 0.f, 1.f}) {
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
eve::climbing::ClimbingProfile profile;
|
|
profile.scoreWeights = weights;
|
|
profile.actions.push_back(mantleAction());
|
|
REQUIRE(runtime.setProfile(std::move(profile)).ok());
|
|
eve::climbing::ClimbingPose pose{{0.f, 0.f, 0.f}, forward, 1.f, -1};
|
|
pose.moveIntent = {1.f, 0.f, 0.f};
|
|
pose.lookIntent = {0.f, 0.f, 1.f};
|
|
pose.inputMode = mode;
|
|
auto candidates = runtime.probe(*fixture.world, pose);
|
|
REQUIRE(candidates.ok());
|
|
REQUIRE_EQ(candidates.value().size(), std::size_t(1));
|
|
return candidates.value().front().score;
|
|
};
|
|
|
|
eve::climbing::ClimbingScoreWeights zero{};
|
|
zero.direction = zero.approachSpeed = zero.height = zero.distance = 0;
|
|
zero.warpTranslation = zero.warpRotation = zero.intentMismatch = 0;
|
|
CHECK_EQ(scoreFor(zero, eve::climbing::ClimbingInputMode::Flow), std::int64_t(0));
|
|
|
|
auto translation = zero;
|
|
translation.warpTranslation = 1000;
|
|
CHECK(scoreFor(translation, eve::climbing::ClimbingInputMode::Flow) > 0);
|
|
|
|
auto rotation = zero;
|
|
rotation.warpRotation = 1000;
|
|
CHECK(scoreFor(rotation, eve::climbing::ClimbingInputMode::Flow, {0.35f, 0.f, 0.93675f}) > 0);
|
|
|
|
auto mismatch = zero;
|
|
mismatch.intentMismatch = 1000;
|
|
const auto flowCost = scoreFor(mismatch, eve::climbing::ClimbingInputMode::Flow);
|
|
const auto precisionCost = scoreFor(mismatch, eve::climbing::ClimbingInputMode::Precision);
|
|
CHECK(flowCost > precisionCost);
|
|
CHECK_EQ(precisionCost, std::int64_t(0));
|
|
}
|
|
|
|
TEST_CASE("climbing.selectionRepetitionPenaltyAppliesAfterCommittedExecution") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto action = mantleAction();
|
|
action.repetitionPenalty = 321;
|
|
REQUIRE(runtime.upsertAction(std::move(action)).ok());
|
|
const eve::climbing::ClimbingPose pose{{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1};
|
|
auto before = runtime.probe(*fixture.world, pose);
|
|
REQUIRE(before.ok());
|
|
REQUIRE_EQ(before.value().size(), std::size_t(1));
|
|
const auto beforeScore = before.value().front().score;
|
|
REQUIRE(runtime.tryBegin(*fixture.world, pose, eve::SimulationTick(1)).ok());
|
|
REQUIRE(runtime.cancel(eve::climbing::ClimbingCancelReason::PlayerRequest, eve::SimulationTick(1)).ok());
|
|
auto after = runtime.probe(*fixture.world, pose);
|
|
REQUIRE(after.ok());
|
|
REQUIRE_EQ(after.value().size(), std::size_t(1));
|
|
CHECK_EQ(after.value().front().score - beforeScore, std::int64_t(321));
|
|
}
|
|
|
|
TEST_CASE("climbing.executionRejectsDuplicateTicksAndSupportsExplicitCancellation") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto added = runtime.upsertAction(mantleAction());
|
|
REQUIRE(added.ok());
|
|
const eve::climbing::ClimbingPose pose{{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1};
|
|
auto begun = runtime.tryBegin(*fixture.world, pose, eve::SimulationTick(10));
|
|
REQUIRE(begun.ok());
|
|
CHECK_EQ(static_cast<int>(runtime.phase()), static_cast<int>(eve::climbing::ClimbingPhase::Requested));
|
|
|
|
auto duplicate = runtime.advance(*fixture.world, {eve::SimulationTick(10), seconds(1.0 / 60.0)});
|
|
CHECK(!duplicate.ok());
|
|
CHECK_EQ(static_cast<int>(duplicate.code()), static_cast<int>(eve::StatusCode::Conflict));
|
|
auto cancelled = runtime.cancel(eve::climbing::ClimbingCancelReason::PlayerRequest, eve::SimulationTick(10));
|
|
REQUIRE(cancelled.ok());
|
|
CHECK_EQ(static_cast<int>(runtime.phase()), static_cast<int>(eve::climbing::ClimbingPhase::Cancelled));
|
|
}
|
|
|
|
TEST_CASE("climbing.rootMotionWarpRespectsPerTickTranslationAndFacingCaps") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
eve::climbing::ClimbingProfile profile;
|
|
auto action = mantleAction();
|
|
action.maxTranslationWarpPerTick = 0.05f;
|
|
action.maxYawWarpRadiansPerTick = 0.04f;
|
|
action.horizontalWarpBudget = 0.2f;
|
|
action.verticalWarpBudget = 0.2f;
|
|
action.facingWarpBudgetRadians = 0.2f;
|
|
profile.actions.push_back(action);
|
|
REQUIRE(runtime.setProfile(profile).ok());
|
|
REQUIRE(
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(10)).ok());
|
|
|
|
eve::climbing::ClimbingMotionInput motion;
|
|
motion.hasRootMotion = true;
|
|
motion.facing = {1.f, 0.f, 0.f};
|
|
auto advanced = runtime.advance(*fixture.world, {eve::SimulationTick(11), seconds(0.1)}, motion);
|
|
REQUIRE(advanced.ok());
|
|
const auto& warp = advanced.value().appliedWarp;
|
|
CHECK(std::sqrt(warp.x * warp.x + warp.y * warp.y + warp.z * warp.z) <= 0.0501f);
|
|
CHECK(std::fabs(advanced.value().desiredYawDelta) <= 0.0401f);
|
|
CHECK(advanced.value().executionId == runtime.executionId());
|
|
}
|
|
|
|
TEST_CASE("climbing.invalidPelvisMotionDoesNotConsumeSimulationTick") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
REQUIRE(runtime.upsertAction(mantleAction()).ok());
|
|
REQUIRE(
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(20)).ok());
|
|
|
|
eve::climbing::ClimbingMotionInput invalid;
|
|
invalid.pelvisOffset = {0.f, 0.36f, 0.f};
|
|
auto rejected = runtime.advance(*fixture.world, {eve::SimulationTick(21), seconds(0.1)}, invalid);
|
|
CHECK(!rejected.ok());
|
|
|
|
auto retried = runtime.advance(*fixture.world, {eve::SimulationTick(21), seconds(0.1)});
|
|
REQUIRE(retried.ok());
|
|
CHECK_EQ(retried.value().executionId.value(), std::uint64_t(1));
|
|
}
|
|
|
|
TEST_CASE("climbing.semanticNotifiesDrivePerHandContactsAndCollisionRequest") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
REQUIRE(runtime.upsertAction(mantleAction()).ok());
|
|
REQUIRE(
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(40)).ok());
|
|
|
|
eve::climbing::ClimbingMotionInput left;
|
|
left.notifies = {eve::climbing::ClimbingNotifyKind::ContactLeftHand};
|
|
auto first = runtime.advance(*fixture.world, {eve::SimulationTick(41), seconds(0.1)}, left);
|
|
REQUIRE(first.ok());
|
|
auto firstEvents = runtime.drainEvents();
|
|
REQUIRE(firstEvents.ok());
|
|
CHECK_EQ(firstEvents.value().size(), std::size_t(2)); // started, then left contact
|
|
CHECK(std::fabs(first.value().leftHandWeight - 1.f) < 1e-6f);
|
|
CHECK(std::fabs(first.value().rightHandWeight) < 1e-6f);
|
|
|
|
eve::climbing::ClimbingMotionInput right;
|
|
right.notifies = {eve::climbing::ClimbingNotifyKind::ContactLeftHand,
|
|
eve::climbing::ClimbingNotifyKind::ContactRightHand,
|
|
eve::climbing::ClimbingNotifyKind::CollisionCompact};
|
|
auto second = runtime.advance(*fixture.world, {eve::SimulationTick(42), seconds(0.1)}, right);
|
|
REQUIRE(second.ok());
|
|
auto secondEvents = runtime.drainEvents();
|
|
REQUIRE(secondEvents.ok());
|
|
REQUIRE_EQ(secondEvents.value().size(), std::size_t(1));
|
|
CHECK_EQ(static_cast<int>(secondEvents.value().front().kind),
|
|
static_cast<int>(eve::climbing::ClimbingEventKind::ContactRightHand));
|
|
CHECK(std::fabs(second.value().leftHandWeight - 1.f) < 1e-6f);
|
|
CHECK(std::fabs(second.value().rightHandWeight - 1.f) < 1e-6f);
|
|
CHECK(second.value().compactCollisionRequested);
|
|
CHECK(second.value().compactCollisionActive);
|
|
|
|
eve::climbing::ClimbingMotionInput branch;
|
|
branch.notifies = {eve::climbing::ClimbingNotifyKind::BranchOpen, eve::climbing::ClimbingNotifyKind::BranchClose,
|
|
eve::climbing::ClimbingNotifyKind::BranchOpen};
|
|
auto third = runtime.advance(*fixture.world, {eve::SimulationTick(43), seconds(0.1)}, branch);
|
|
REQUIRE(third.ok());
|
|
CHECK(!third.value().compactCollisionRequested);
|
|
CHECK(third.value().compactCollisionActive);
|
|
CHECK(third.value().branchWindowOpen);
|
|
|
|
eve::climbing::ClimbingMotionInput landed;
|
|
landed.notifies = {eve::climbing::ClimbingNotifyKind::Land};
|
|
auto fourth = runtime.advance(*fixture.world, {eve::SimulationTick(44), seconds(0.1)}, landed);
|
|
REQUIRE(fourth.ok());
|
|
CHECK(std::fabs(fourth.value().contactWeight) < 1e-6f);
|
|
CHECK(std::fabs(fourth.value().leftHandWeight) < 1e-6f);
|
|
CHECK(std::fabs(fourth.value().rightHandWeight) < 1e-6f);
|
|
CHECK(!fourth.value().compactCollisionActive);
|
|
CHECK(!fourth.value().branchWindowOpen);
|
|
}
|
|
|
|
TEST_CASE("climbing.definitionWindowsDriveBranchAndContactWeightsWithoutNotifies") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto action = mantleAction();
|
|
action.duration = seconds(1.0);
|
|
action.branchWindows = {{0.2f, 0.3f, "combo:follow_up"}};
|
|
action.comboTags = {"combo:follow_up"};
|
|
action.contactConstraints = {
|
|
{eve::climbing::ClimbingContactTarget::LeftHand, 0.2f, 0.3f, 0.7f},
|
|
{eve::climbing::ClimbingContactTarget::LeftFoot, 0.2f, 0.3f, 0.5f},
|
|
{eve::climbing::ClimbingContactTarget::Pelvis, 0.2f, 0.3f, 0.4f},
|
|
};
|
|
REQUIRE(runtime.upsertAction(std::move(action)).ok());
|
|
REQUIRE(
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(100)).ok());
|
|
|
|
auto before = runtime.advance(*fixture.world, {eve::SimulationTick(101), seconds(0.1)});
|
|
REQUIRE(before.ok());
|
|
CHECK(!before.value().branchWindowOpen);
|
|
CHECK(before.value().leftHandWeight == 0.f);
|
|
|
|
auto inside = runtime.advance(*fixture.world, {eve::SimulationTick(102), seconds(0.15)});
|
|
REQUIRE(inside.ok());
|
|
CHECK(inside.value().branchWindowOpen);
|
|
CHECK_EQ(inside.value().branchComboTag, std::string("combo:follow_up"));
|
|
CHECK(std::fabs(inside.value().leftHandWeight - 0.7f) < 0.001f);
|
|
CHECK(std::fabs(inside.value().leftFootWeight - 0.5f) < 0.001f);
|
|
CHECK(std::fabs(inside.value().pelvisWeight - 0.4f) < 0.001f);
|
|
|
|
auto after = runtime.advance(*fixture.world, {eve::SimulationTick(103), seconds(0.1)});
|
|
REQUIRE(after.ok());
|
|
CHECK(!after.value().branchWindowOpen);
|
|
CHECK(after.value().leftHandWeight == 0.f);
|
|
}
|
|
|
|
TEST_CASE("climbing.landingAndTerminalVelocityPoliciesPublishAuthoritativeVelocity") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto action = mantleAction();
|
|
action.duration = seconds(0.1);
|
|
action.landingPolicy = eve::climbing::ClimbingLandingPolicy::Stop;
|
|
action.terminalVelocityPolicy = eve::climbing::ClimbingTerminalVelocityPolicy::Preserve;
|
|
REQUIRE(runtime.upsertAction(std::move(action)).ok());
|
|
REQUIRE(
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(200)).ok());
|
|
|
|
auto completed = runtime.advance(*fixture.world, {eve::SimulationTick(201), seconds(0.1)});
|
|
REQUIRE(completed.ok());
|
|
CHECK(static_cast<int>(completed.value().phase) == static_cast<int>(eve::climbing::ClimbingPhase::Completed));
|
|
CHECK(completed.value().hasTerminalVelocity);
|
|
CHECK(completed.value().terminalVelocity.x == 0.f);
|
|
CHECK(completed.value().terminalVelocity.y == 0.f);
|
|
CHECK(completed.value().terminalVelocity.z == 0.f);
|
|
}
|
|
|
|
TEST_CASE("climbing.runtimeCountersExposeBoundedProbeTelemetryWithDebugDisabled") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
runtime.setDebugCapture(eve::climbing::ClimbingDebugCapture::Disabled);
|
|
REQUIRE(runtime.upsertAction(mantleAction()).ok());
|
|
eve::climbing::ClimbingCandidateSet candidates;
|
|
REQUIRE(runtime
|
|
.probeInto(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, candidates,
|
|
eve::SimulationTick(300))
|
|
.ok());
|
|
const auto counters = runtime.counters();
|
|
CHECK(static_cast<int>(counters.workload) == static_cast<int>(eve::climbing::ClimbingWorkload::CandidateProbe));
|
|
CHECK(counters.queryCount <= eve::climbing::ClimbingQueryBudgets::CandidateProbe);
|
|
CHECK_EQ(counters.broadPhaseQueryCount, std::uint32_t{1});
|
|
CHECK(counters.broadPhaseHitCount >= 1);
|
|
CHECK(counters.selectedCost == candidates.front().score);
|
|
const auto summary = runtime.telemetry().summarize(eve::climbing::ClimbingWorkload::CandidateProbe);
|
|
CHECK(summary.sampleCount == 1);
|
|
CHECK(summary.maxQueryCount == counters.queryCount);
|
|
|
|
runtime.recordOrdinaryTick(eve::SimulationTick(301));
|
|
CHECK(runtime.counters().queryCount == 0);
|
|
CHECK(runtime.counters().queryBudget == eve::climbing::ClimbingQueryBudgets::Ordinary);
|
|
}
|
|
|
|
TEST_CASE("climbing.probeDebugOffWarmPathPerformsNoPerTickHeapAllocation") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
runtime.setDebugCapture(eve::climbing::ClimbingDebugCapture::Disabled);
|
|
REQUIRE(runtime.upsertAction(mantleAction("parkour:vault_long_identity_that_exceeds_small_string_storage")).ok());
|
|
const eve::climbing::ClimbingPose pose{{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1};
|
|
eve::climbing::ClimbingCandidateSet output;
|
|
for (std::uint64_t tick = 1; tick <= 4; ++tick)
|
|
REQUIRE(runtime.probeInto(*fixture.world, pose, output, eve::SimulationTick(tick)).ok());
|
|
|
|
#if defined(_MSC_VER)
|
|
trackedHeapAllocations = 0;
|
|
const _CRT_ALLOC_HOOK previousHook = _CrtSetAllocHook(&climbingAllocationHook);
|
|
trackHeapAllocations = true;
|
|
auto probed = runtime.probeInto(*fixture.world, pose, output, eve::SimulationTick(5));
|
|
trackHeapAllocations = false;
|
|
_CrtSetAllocHook(previousHook);
|
|
REQUIRE(probed.ok());
|
|
CHECK_EQ(trackedHeapAllocations, std::size_t(0));
|
|
#else
|
|
// The fixed-capacity storage path is exercised on every platform; MSVC additionally proves CRT allocation count.
|
|
REQUIRE(runtime.probeInto(*fixture.world, pose, output, eve::SimulationTick(5)).ok());
|
|
#endif
|
|
REQUIRE_EQ(output.size(), std::size_t(1));
|
|
}
|
|
|
|
TEST_CASE("climbing.authoredMotionFailsExplicitlyWhenWarpCannotConverge") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
eve::climbing::ClimbingProfile profile;
|
|
auto action = mantleAction();
|
|
action.duration = seconds(0.2);
|
|
action.maxTranslationWarpPerTick = 0.01f;
|
|
action.horizontalWarpBudget = 0.01f;
|
|
action.verticalWarpBudget = 0.01f;
|
|
profile.maxWarpResidual = 0.02f;
|
|
profile.actions.push_back(action);
|
|
REQUIRE(runtime.setProfile(profile).ok());
|
|
REQUIRE(
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(30)).ok());
|
|
|
|
eve::climbing::ClimbingMotionInput motion;
|
|
motion.hasRootMotion = true;
|
|
auto advanced = runtime.advance(*fixture.world, {eve::SimulationTick(31), seconds(0.2)}, motion);
|
|
REQUIRE(advanced.ok());
|
|
CHECK_EQ(static_cast<int>(advanced.value().phase), static_cast<int>(eve::climbing::ClimbingPhase::Failed));
|
|
CHECK_EQ(runtime.inspect().terminalCode, std::string("climbing.warp.budget_exceeded"));
|
|
CHECK(runtime.executionId().isZero());
|
|
}
|
|
|
|
TEST_CASE("climbing.ledgeGrabTransitionsToHangAndDrop") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto added = runtime.upsertAction(ledgeGrabAction());
|
|
REQUIRE(added.ok());
|
|
const eve::climbing::ClimbingPose pose{{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1, -1.f, false};
|
|
auto begun = runtime.tryBegin(*fixture.world, pose, eve::SimulationTick(10));
|
|
REQUIRE(begun.ok());
|
|
CHECK_EQ(static_cast<int>(begun.value().kind), static_cast<int>(eve::climbing::ClimbingActionKind::LedgeGrab));
|
|
CHECK_NE(static_cast<int>(begun.value().support), static_cast<int>(eve::climbing::HangSupport::None));
|
|
|
|
for (std::uint64_t tick = 11; tick <= 16; ++tick) {
|
|
auto advanced = runtime.advance(*fixture.world, {eve::SimulationTick(tick), seconds(0.1)});
|
|
REQUIRE(advanced.ok());
|
|
}
|
|
auto events = runtime.drainEvents();
|
|
REQUIRE(events.ok());
|
|
const bool sawHangingEvent = std::any_of(events.value().begin(), events.value().end(), [](const auto& event) {
|
|
return event.kind == eve::climbing::ClimbingEventKind::Hanging;
|
|
});
|
|
CHECK_EQ(static_cast<int>(runtime.phase()), static_cast<int>(eve::climbing::ClimbingPhase::Hanging));
|
|
CHECK(sawHangingEvent);
|
|
auto debug = runtime.inspect();
|
|
CHECK_EQ(debug.candidates.size(), std::size_t{1});
|
|
CHECK(debug.queryCount >= 4);
|
|
CHECK(!debug.queries.empty());
|
|
CHECK(!debug.evidence.empty());
|
|
CHECK(!debug.motion.empty());
|
|
CHECK_EQ(debug.evidence.front().code, std::string("accepted"));
|
|
runtime.setDebugCapture(eve::climbing::ClimbingDebugCapture::Disabled);
|
|
auto disabledDebug = runtime.inspect();
|
|
CHECK(disabledDebug.candidates.empty());
|
|
CHECK(disabledDebug.queries.empty());
|
|
CHECK(disabledDebug.evidence.empty());
|
|
CHECK(disabledDebug.motion.empty());
|
|
|
|
auto dropped = runtime.drop(eve::SimulationTick(17));
|
|
REQUIRE(dropped.ok());
|
|
CHECK_EQ(static_cast<int>(runtime.phase()), static_cast<int>(eve::climbing::ClimbingPhase::Dropping));
|
|
auto falling = runtime.advance(*fixture.world, {eve::SimulationTick(18), seconds(1.0 / 60.0)});
|
|
REQUIRE(falling.ok());
|
|
CHECK(falling.value().desiredDelta.y < 0.f);
|
|
auto dropEvents = runtime.drainEvents();
|
|
REQUIRE(dropEvents.ok());
|
|
REQUIRE_EQ(dropEvents.value().size(), std::size_t(1));
|
|
CHECK_EQ(static_cast<int>(dropEvents.value().front().kind),
|
|
static_cast<int>(eve::climbing::ClimbingEventKind::Dropped));
|
|
}
|
|
|
|
TEST_CASE("climbing.activeExecutionCancelsWhenDynamicAnchorBecomesStale") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto added = runtime.upsertAction(ledgeGrabAction());
|
|
REQUIRE(added.ok());
|
|
auto begun = runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1, -1.f, false},
|
|
eve::SimulationTick(20));
|
|
REQUIRE(begun.ok());
|
|
fixture.world->destroyBody(fixture.obstacle);
|
|
fixture.obstacle = nullptr;
|
|
fixture.shape = nullptr;
|
|
auto advanced = runtime.advance(*fixture.world, {eve::SimulationTick(21), seconds(1.0 / 60.0)});
|
|
CHECK(!advanced.ok());
|
|
CHECK_EQ(static_cast<int>(runtime.phase()), static_cast<int>(eve::climbing::ClimbingPhase::Cancelled));
|
|
CHECK_EQ(runtime.inspect().terminalCode, std::string("climbing.anchor.stale"));
|
|
auto events = runtime.drainEvents();
|
|
REQUIRE(events.ok());
|
|
REQUIRE_EQ(events.value().size(), std::size_t(2));
|
|
CHECK_EQ(static_cast<int>(events.value().back().kind),
|
|
static_cast<int>(eve::climbing::ClimbingEventKind::Cancelled));
|
|
}
|
|
|
|
TEST_CASE("climbing.animationNotifyContractIsValidatedBeforeExecution") {
|
|
ClimbingWorld fixture;
|
|
eve::climbing::ClimbingRuntime runtime;
|
|
auto action = mantleAction("core:notify_mantle");
|
|
action.requiredNotifies = {"contact.left_hand", "contact.right_hand", "land"};
|
|
REQUIRE(runtime.upsertAction(action).ok());
|
|
|
|
eve::animation::AnimClip incomplete("incomplete-mantle");
|
|
incomplete.setDuration(0.6f);
|
|
incomplete.addEvent(0.2f, "contact.left_hand");
|
|
auto invalid = runtime.validateAnimationBinding(action.id, incomplete);
|
|
CHECK(!invalid.ok());
|
|
auto rejected =
|
|
runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(30));
|
|
CHECK(!rejected.ok());
|
|
|
|
eve::animation::AnimClip complete("complete-mantle");
|
|
complete.setDuration(0.6f);
|
|
complete.addEvent(0.2f, "contact.left_hand");
|
|
complete.addEvent(0.3f, "contact.right_hand");
|
|
complete.addEvent(0.55f, "land");
|
|
REQUIRE(runtime.validateAnimationBinding(action.id, complete).ok());
|
|
auto begun = runtime.tryBegin(*fixture.world, {{0.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, 1.f, -1}, eve::SimulationTick(31));
|
|
REQUIRE(begun.ok());
|
|
}
|