From 27cc901e7685c931d12c119f4a8cf5b7565d91b7 Mon Sep 17 00:00:00 2001 From: elsid Date: Sun, 10 Jul 2022 17:27:00 +0200 Subject: [PATCH] Add bindings for navigator utils functions --- apps/openmw/mwlua/luabindings.cpp | 2 +- apps/openmw/mwlua/nearbybindings.cpp | 141 ++++++++++++++++++ apps/openmw/mwlua/types/actor.cpp | 9 ++ apps/openmw/mwworld/worldimp.cpp | 2 +- components/detournavigator/findsmoothpath.hpp | 2 +- components/detournavigator/navigatorutils.hpp | 4 +- files/lua_api/openmw/nearby.lua | 124 ++++++++++++++- files/lua_api/openmw/types.lua | 6 + .../integration_tests/test_lua_api/player.lua | 46 ++++++ .../integration_tests/test_lua_api/test.lua | 13 +- .../test_lua_api/testing_util.lua | 19 ++- 11 files changed, 360 insertions(+), 8 deletions(-) diff --git a/apps/openmw/mwlua/luabindings.cpp b/apps/openmw/mwlua/luabindings.cpp index 705209a603..d7fc00d07d 100644 --- a/apps/openmw/mwlua/luabindings.cpp +++ b/apps/openmw/mwlua/luabindings.cpp @@ -54,7 +54,7 @@ namespace MWLua { auto* lua = context.mLua; sol::table api(lua->sol(), sol::create); - api["API_REVISION"] = 27; + api["API_REVISION"] = 28; api["quit"] = [lua]() { Log(Debug::Warning) << "Quit requested by a Lua script.\n" << lua->debugTraceback(); diff --git a/apps/openmw/mwlua/nearbybindings.cpp b/apps/openmw/mwlua/nearbybindings.cpp index 3ad9baac61..8e09284518 100644 --- a/apps/openmw/mwlua/nearbybindings.cpp +++ b/apps/openmw/mwlua/nearbybindings.cpp @@ -1,6 +1,9 @@ #include "luabindings.hpp" #include +#include +#include +#include #include "../mwbase/environment.hpp" #include "../mwbase/world.hpp" @@ -122,6 +125,144 @@ namespace MWLua api["containers"] = LObjectList{worldView->getContainersInScene()}; api["doors"] = LObjectList{worldView->getDoorsInScene()}; api["items"] = LObjectList{worldView->getItemsInScene()}; + + api["NAVIGATOR_FLAGS"] = LuaUtil::makeStrictReadOnly( + context.mLua->tableFromPairs({ + {"Walk", DetourNavigator::Flag_walk}, + {"Swim", DetourNavigator::Flag_swim}, + {"OpenDoor", DetourNavigator::Flag_openDoor}, + {"UsePathgrid", DetourNavigator::Flag_usePathgrid}, + })); + + api["COLLISION_SHAPE_TYPE"] = LuaUtil::makeStrictReadOnly( + context.mLua->tableFromPairs({ + {"Aabb", DetourNavigator::CollisionShapeType::Aabb}, + {"RotatingBox", DetourNavigator::CollisionShapeType::RotatingBox}, + })); + + api["FIND_PATH_STATUS"] = LuaUtil::makeStrictReadOnly( + context.mLua->tableFromPairs({ + {"Success", DetourNavigator::Status::Success}, + {"PartialPath", DetourNavigator::Status::PartialPath}, + {"NavMeshNotFound", DetourNavigator::Status::NavMeshNotFound}, + {"StartPolygonNotFound", DetourNavigator::Status::StartPolygonNotFound}, + {"EndPolygonNotFound", DetourNavigator::Status::EndPolygonNotFound}, + {"MoveAlongSurfaceFailed", DetourNavigator::Status::MoveAlongSurfaceFailed}, + {"FindPathOverPolygonsFailed", DetourNavigator::Status::FindPathOverPolygonsFailed}, + {"GetPolyHeightFailed", DetourNavigator::Status::GetPolyHeightFailed}, + {"InitNavMeshQueryFailed", DetourNavigator::Status::InitNavMeshQueryFailed}, + })); + + static const DetourNavigator::AgentBounds defaultAgentBounds { + DetourNavigator::defaultCollisionShapeType, + Settings::Manager::getVector3("default actor pathfind half extents", "Game"), + }; + static const float defaultStepSize = 2 * std::max(defaultAgentBounds.mHalfExtents.x(), defaultAgentBounds.mHalfExtents.y()); + static constexpr DetourNavigator::Flags defaultIncludeFlags = DetourNavigator::Flag_walk + | DetourNavigator::Flag_swim + | DetourNavigator::Flag_openDoor + | DetourNavigator::Flag_usePathgrid; + + api["findPath"] = [] (const osg::Vec3f& source, const osg::Vec3f& destination, + const sol::optional& options) + { + DetourNavigator::AgentBounds agentBounds = defaultAgentBounds; + float stepSize = defaultStepSize; + DetourNavigator::Flags includeFlags = defaultIncludeFlags; + DetourNavigator::AreaCosts areaCosts {}; + float destinationTolerance = 1; + + if (options.has_value()) + { + if (const auto& t = options->get>("agentBounds")) + { + if (const auto& v = t->get>("shapeType")) + agentBounds.mShapeType = *v; + if (const auto& v = t->get>("halfExtents")) + { + agentBounds.mHalfExtents = *v; + stepSize = 2 * std::max(v->x(), v->y()); + } + } + if (const auto& v = options->get>("stepSize")) + stepSize = *v; + if (const auto& v = options->get>("includeFlags")) + includeFlags = *v; + if (const auto& t = options->get>("areaCosts")) + { + if (const auto& v = t->get>("water")) + areaCosts.mWater = *v; + if (const auto& v = t->get>("door")) + areaCosts.mDoor = *v; + if (const auto& v = t->get>("pathgrid")) + areaCosts.mPathgrid = *v; + if (const auto& v = t->get>("ground")) + areaCosts.mGround = *v; + } + if (const auto& v = options->get>("destinationTolerance")) + destinationTolerance = *v; + } + + std::vector result; + + const DetourNavigator::Status status = DetourNavigator::findPath( + *MWBase::Environment::get().getWorld()->getNavigator(), agentBounds, stepSize, source, + destination, includeFlags, areaCosts, destinationTolerance, std::back_inserter(result)); + + return std::make_tuple(status, std::move(result)); + }; + + api["findRandomPointAroundCircle"] = [] (const osg::Vec3f& position, float maxRadius, + const sol::optional& options) + { + DetourNavigator::AgentBounds agentBounds = defaultAgentBounds; + DetourNavigator::Flags includeFlags = defaultIncludeFlags; + + if (options.has_value()) + { + if (const auto& t = options->get>("agentBounds")) + { + if (const auto& v = t->get>("shapeType")) + agentBounds.mShapeType = *v; + if (const auto& v = t->get>("halfExtents")) + agentBounds.mHalfExtents = *v; + } + if (const auto& v = options->get>("includeFlags")) + includeFlags = *v; + } + + constexpr auto getRandom = [] + { + return Misc::Rng::rollProbability(MWBase::Environment::get().getWorld()->getPrng()); + }; + + return DetourNavigator::findRandomPointAroundCircle(*MWBase::Environment::get().getWorld()->getNavigator(), + agentBounds, position, maxRadius, includeFlags, getRandom); + }; + + api["castNavigationRay"] = [] (const osg::Vec3f& from, const osg::Vec3f& to, + const sol::optional& options) + { + DetourNavigator::AgentBounds agentBounds = defaultAgentBounds; + DetourNavigator::Flags includeFlags = defaultIncludeFlags; + + if (options.has_value()) + { + if (const auto& t = options->get>("agentBounds")) + { + if (const auto& v = t->get>("shapeType")) + agentBounds.mShapeType = *v; + if (const auto& v = t->get>("halfExtents")) + agentBounds.mHalfExtents = *v; + } + if (const auto& v = options->get>("includeFlags")) + includeFlags = *v; + } + + return DetourNavigator::raycast(*MWBase::Environment::get().getWorld()->getNavigator(), + agentBounds, from, to, includeFlags); + }; + return LuaUtil::makeReadOnly(api); } } diff --git a/apps/openmw/mwlua/types/actor.cpp b/apps/openmw/mwlua/types/actor.cpp index 0e89b22052..acfacfd0fa 100644 --- a/apps/openmw/mwlua/types/actor.cpp +++ b/apps/openmw/mwlua/types/actor.cpp @@ -1,6 +1,7 @@ #include "types.hpp" #include +#include #include #include @@ -243,6 +244,14 @@ namespace MWLua } context.mLuaManager->addAction(std::make_unique(context.mLua, obj.id(), std::move(eqp))); }; + actor["getPathfindingAgentBounds"] = [context](const LObject& o) + { + const DetourNavigator::AgentBounds agentBounds = MWBase::Environment::get().getWorld()->getPathfindingAgentBounds(o.ptr()); + sol::table result = context.mLua->newTable(); + result["shapeType"] = agentBounds.mShapeType; + result["halfExtents"] = agentBounds.mHalfExtents; + return result; + }; addActorStatsBindings(actor, context); } diff --git a/apps/openmw/mwworld/worldimp.cpp b/apps/openmw/mwworld/worldimp.cpp index 1f5532655d..3bde9c8b97 100644 --- a/apps/openmw/mwworld/worldimp.cpp +++ b/apps/openmw/mwworld/worldimp.cpp @@ -3931,7 +3931,7 @@ namespace MWWorld DetourNavigator::AgentBounds World::getPathfindingAgentBounds(const MWWorld::ConstPtr& actor) const { const MWPhysics::Actor* physicsActor = mPhysics->getActor(actor); - if (physicsActor == nullptr || (actor.isInCell() && actor.getCell()->isExterior())) + if (physicsActor == nullptr || !actor.isInCell() || actor.getCell()->isExterior()) return DetourNavigator::AgentBounds {DetourNavigator::defaultCollisionShapeType, mDefaultHalfExtents}; else return DetourNavigator::AgentBounds {physicsActor->getCollisionShapeType(), physicsActor->getHalfExtents()}; diff --git a/components/detournavigator/findsmoothpath.hpp b/components/detournavigator/findsmoothpath.hpp index aade62ad62..537d6bde2e 100644 --- a/components/detournavigator/findsmoothpath.hpp +++ b/components/detournavigator/findsmoothpath.hpp @@ -256,7 +256,7 @@ namespace DetourNavigator template Status findSmoothPath(const dtNavMesh& navMesh, const osg::Vec3f& halfExtents, const float stepSize, const osg::Vec3f& start, const osg::Vec3f& end, const Flags includeFlags, const AreaCosts& areaCosts, - const Settings& settings, float endTolerance, OutputIterator& out) + const Settings& settings, float endTolerance, OutputIterator out) { dtNavMeshQuery navMeshQuery; if (!initNavMeshQuery(navMeshQuery, navMesh, settings.mDetour.mMaxNavMeshQueryNodes)) diff --git a/components/detournavigator/navigatorutils.hpp b/components/detournavigator/navigatorutils.hpp index 44db7b40fe..589334ae33 100644 --- a/components/detournavigator/navigatorutils.hpp +++ b/components/detournavigator/navigatorutils.hpp @@ -24,7 +24,7 @@ namespace DetourNavigator template inline Status findPath(const Navigator& navigator, const AgentBounds& agentBounds, const float stepSize, const osg::Vec3f& start, const osg::Vec3f& end, const Flags includeFlags, const AreaCosts& areaCosts, - float endTolerance, OutputIterator& out) + float endTolerance, OutputIterator out) { static_assert( std::is_same< @@ -45,7 +45,7 @@ namespace DetourNavigator /** * @brief findRandomPointAroundCircle returns random location on navmesh within the reach of specified location. * @param agentBounds allows to find navmesh for given actor. - * @param start path from given point. + * @param start is a position where the search starts. * @param maxRadius limit maximum distance from start. * @param includeFlags setup allowed surfaces for actor to walk. * @return not empty optional with position if point is found and empty optional if point is not found. diff --git a/files/lua_api/openmw/nearby.lua b/files/lua_api/openmw/nearby.lua index 076d5cebbd..47ed77f5ef 100644 --- a/files/lua_api/openmw/nearby.lua +++ b/files/lua_api/openmw/nearby.lua @@ -88,5 +88,127 @@ -- @param openmw.util#Vector3 from Start point of the ray. -- @param openmw.util#Vector3 to End point of the ray. -return nil +--- +-- @type NAVIGATOR_FLAGS +-- @field [parent=#NAVIGATOR_FLAGS] #number Walk allow agent to walk on the ground area; +-- @field [parent=#NAVIGATOR_FLAGS] #number Swim allow agent to swim on the water surface; +-- @field [parent=#NAVIGATOR_FLAGS] #number OpenDoor allow agent to open doors on the way; +-- @field [parent=#NAVIGATOR_FLAGS] #number UsePathgrid allow agent to use predefined pathgrid imported from ESM files. + +--- +-- @type COLLISION_SHAPE_TYPE +-- @field [parent=#CCOLLISION_SHAPE_TYPE] #number Aabb Axis-Aligned Bounding Box is used for NPC and symmetric +-- Creatures; +-- @field [parent=#COLLISION_SHAPE_TYPE] #number RotatingBox is used for Creatures with big difference in width and +-- height. + +--- +-- @type FIND_PATH_STATUS +-- @field [parent=#FIND_PATH_STATUS] #number Success Path is found; +-- @field [parent=#FIND_PATH_STATUS] #number PartialPath Last path point is not a destination but a nearest position +-- among found; +-- @field [parent=#FIND_PATH_STATUS] #number NavMeshNotFound Provided `agentBounds` don't have corresponding navigation +-- mesh. For interior cells it means an agent with such `agentBounds` is present on the scene. For exterior cells only +-- default `agentBounds` is supported; +-- @field [parent=#FIND_PATH_STATUS] #number StartPolygonNotFound `source` position is too far from available +-- navigation mesh. The status may appear when navigation mesh is not fully generated or position is outside of covered +-- area; +-- @field [parent=#FIND_PATH_STATUS] #number EndPolygonNotFound `destination` position is too far from available +-- navigation mesh. The status may appear when navigation mesh is not fully generated or position is outside of covered +-- area; +-- @field [parent=#FIND_PATH_STATUS] #number MoveAlongSurfaceFailed Found path couldn't be smoothed due to imperfect +-- algorithm implementation or bad navigation mesh data; +-- @field [parent=#FIND_PATH_STATUS] #number FindPathOverPolygonsFailed Path over navigation mesh from `source` to +-- `destination` does not exist or navigation mesh is not fully generated to provide the path; +-- @field [parent=#FIND_PATH_STATUS] #number GetPolyHeightFailed Found path couldn't be smoothed due to imperfect +-- algorithm implementation or bad navigation mesh data; +-- @field [parent=#FIND_PATH_STATUS] #number InitNavMeshQueryFailed Couldn't initialize required data due to bad input +-- or bad navigation mesh data. + +--- +-- Find path over navigation mesh from source to destination with given options. Result is unstable since navigation +-- mesh generation is asynchronous. +-- @function [parent=#nearby] findPath +-- @param openmw.util#Vector3 source Initial path position. +-- @param openmw.util#Vector3 destination Final path position. +-- @param #table options An optional table with additional optional arguments. Can contain: +-- +-- * `agentBounds` - a table identifying which navmesh to use, can contain: +-- +-- * `shapeType` - one of @{#COLLISION_SHAPE_TYPE} values; +-- * `halfExtents` - @{openmw.util#Vector3} defining agent bounds size; +-- * `stepSize` - a floating point number to define frequency of path points +-- (default: `2 * math.max(halfExtents:x, halfExtents:y)`) +-- * `includeFlags` - allowed areas for agent to move, a sum of @{#NAVIGATOR_FLAGS} values +-- (default: @{#NAVIGATOR_FLAGS.Walk} + @{#NAVIGATOR_FLAGS.Swim} + +-- @{#NAVIGATOR_FLAGS.OpenDoor} + @{#NAVIGATOR_FLAGS.UsePathgrid}); +-- * `areaCosts` - a table defining relative cost for each type of area, can contain: +-- +-- * `ground` - a floating point number >= 0, used in combination with @{#NAVIGATOR_FLAGS.Walk} (default: 1); +-- * `water` - a floating point number >= 0, used in combination with @{#NAVIGATOR_FLAGS.Swim} (default: 1); +-- * `door` - a floating point number >= 0, used in combination with @{#NAVIGATOR_FLAGS.OpenDoor} (default: 2); +-- * `pathgrid` - a floating point number >= 0, used in combination with @{#NAVIGATOR_FLAGS.UsePathgrid} +-- (default: 1); +-- * `destinationTolerance` - a floating point number representing maximum allowed distance between destination and a +-- nearest point on the navigation mesh in addition to agent size (default: 1); +-- @return @{#FIND_PATH_STATUS}, a collection of @{openmw.util#Vector3} +-- @usage local status, path = nearby.findPath(source, destination) +-- @usage local status, path = nearby.findPath(source, destination, { +-- includeFlags = nearby.NAVIGATOR_FLAGS.Walk + nearby.NAVIGATOR_FLAGS.OpenDoor, +-- areaCosts = { +-- door = 1.5, +-- }, +-- }) +-- @usage local status, path = nearby.findPath(source, destination, { +-- agentBounds = Actor.getPathfindingAgentBounds(self), +-- }) +--- +-- Returns random location on navigation mesh within the reach of specified location. +-- The location is not exactly constrained by the circle, but it limits the area. +-- @function [parent=#nearby] findRandomPointAroundCircle +-- @param openmw.util#Vector3 position Center of the search circle. +-- @param #number maxRadius Approximate maximum search distance. +-- @param #table options An optional table with additional optional arguments. Can contain: +-- +-- * `agentBounds` - a table identifying which navmesh to use, can contain: +-- +-- * `shapeType` - one of @{#COLLISION_SHAPE_TYPE} values; +-- * `halfExtents` - @{openmw.util#Vector3} defining agent bounds size; +-- * `includeFlags` - allowed areas for agent to move, a sum of @{#NAVIGATOR_FLAGS} values +-- (default: @{#NAVIGATOR_FLAGS.Walk} + @{#NAVIGATOR_FLAGS.Swim} + +-- @{#NAVIGATOR_FLAGS.OpenDoor} + @{#NAVIGATOR_FLAGS.UsePathgrid}); +-- @return @{openmw.util#Vector3} or nil +-- @usage local position = nearby.findRandomPointAroundCircle(position, maxRadius) +-- @usage local position = nearby.findRandomPointAroundCircle(position, maxRadius, { +-- includeFlags = nearby.NAVIGATOR_FLAGS.Walk, +-- }) +-- @usage local position = nearby.findRandomPointAroundCircle(position, maxRadius, { +-- agentBounds = Actor.getPathfindingAgentBounds(self), +-- }) + +--- +-- Finds a nearest to the ray target position starting from the initial position with resulting curve drawn on the +-- navigation mesh surface. +-- @function [parent=#nearby] castNavigationRay +-- @param openmw.util#Vector3 from Initial ray position. +-- @param openmw.util#Vector3 to Target ray position. +-- @param #table options An optional table with additional optional arguments. Can contain: +-- +-- * `agentBounds` - a table identifying which navmesh to use, can contain: +-- +-- * `shapeType` - one of @{#COLLISION_SHAPE_TYPE} values; +-- * `halfExtents` - @{openmw.util#Vector3} defining agent bounds size; +-- * `includeFlags` - allowed areas for agent to move, a sum of @{#NAVIGATOR_FLAGS} values +-- (default: @{#NAVIGATOR_FLAGS.Walk} + @{#NAVIGATOR_FLAGS.Swim} + +-- @{#NAVIGATOR_FLAGS.OpenDoor} + @{#NAVIGATOR_FLAGS.UsePathgrid}); +-- @return @{openmw.util#Vector3} or nil +-- @usage local position = nearby.castNavigationRay(from, to) +-- @usage local position = nearby.castNavigationRay(from, to, { +-- includeFlags = nearby.NAVIGATOR_FLAGS.Swim, +-- }) +-- @usage local position = nearby.castNavigationRay(from, to, { +-- agentBounds = Actor.getPathfindingAgentBounds(self), +-- }) + +return nil diff --git a/files/lua_api/openmw/types.lua b/files/lua_api/openmw/types.lua index 8ecd7458ba..26a29746b5 100644 --- a/files/lua_api/openmw/types.lua +++ b/files/lua_api/openmw/types.lua @@ -9,6 +9,12 @@ --- Common functions for Creature, NPC, and Player. -- @type Actor +--- +-- Agent bounds to be used for pathfinding functions. +-- @function [parent=#Actor] getPathfindingAgentBounds +-- @param openmw.core#GameObject actor +-- @return #table with `shapeType` and `halfExtents` + --- -- Whether the object is an actor. -- @function [parent=#Actor] objectIsInstance diff --git a/scripts/data/integration_tests/test_lua_api/player.lua b/scripts/data/integration_tests/test_lua_api/player.lua index 63e88b0556..10580ee507 100644 --- a/scripts/data/integration_tests/test_lua_api/player.lua +++ b/scripts/data/integration_tests/test_lua_api/player.lua @@ -4,6 +4,7 @@ local util = require('openmw.util') local core = require('openmw.core') local input = require('openmw.input') local types = require('openmw.types') +local nearby = require('openmw.nearby') input.setControlSwitch(input.CONTROL_SWITCH.Fighting, false) input.setControlSwitch(input.CONTROL_SWITCH.Jumping, false) @@ -64,6 +65,51 @@ testing.registerLocalTest('playerDiagonalWalking', testing.expectEqualWithDelta(direction.y, -0.707, 0.1, 'Walk diagonally, Y coord') end) +testing.registerLocalTest('findPath', + function() + local src = util.vector3(4096, 4096, 867.237) + local dst = util.vector3(4500, 4500, 700.216) + local options = { + agentBounds = types.Actor.getPathfindingAgentBounds(self), + stepSize = 50, + includeFlags = nearby.NAVIGATOR_FLAGS.Walk + nearby.NAVIGATOR_FLAGS.Swim, + areaCosts = { + water = 1, + door = 2, + ground = 1, + pathgrid = 1, + }, + destinationTolerance = 1, + } + local status, path = nearby.findPath(src, dst) + testing.expectEqual(status, nearby.FIND_PATH_STATUS.Success, 'Status') + testing.expectLessOrEqual((path[path:size()] - dst):length(), 1, 'Last path point') + end) + +testing.registerLocalTest('findRandomPointAroundCircle', + function() + local position = util.vector3(4096, 4096, 867.237) + local maxRadius = 100 + local options = { + agentBounds = types.Actor.getPathfindingAgentBounds(self), + includeFlags = nearby.NAVIGATOR_FLAGS.Walk, + } + local result = nearby.findRandomPointAroundCircle(position, maxRadius, options) + testing.expectGreaterThan((result - position):length(), 1, 'Random point') + end) + +testing.registerLocalTest('castNavigationRay', + function() + local src = util.vector3(4096, 4096, 867.237) + local dst = util.vector3(4500, 4500, 700.216) + local options = { + agentBounds = types.Actor.getPathfindingAgentBounds(self), + includeFlags = nearby.NAVIGATOR_FLAGS.Walk + nearby.NAVIGATOR_FLAGS.Swim, + } + local result = nearby.castNavigationRay(src, dst, options) + testing.expectLessOrEqual((result - dst):length(), 1, 'Navigation hit point') + end) + return { engineHandlers = { onUpdate = testing.updateLocal, diff --git a/scripts/data/integration_tests/test_lua_api/test.lua b/scripts/data/integration_tests/test_lua_api/test.lua index ba0d45ab23..5929712a33 100644 --- a/scripts/data/integration_tests/test_lua_api/test.lua +++ b/scripts/data/integration_tests/test_lua_api/test.lua @@ -70,6 +70,18 @@ tests = { initPlayer() testing.runLocalTest(player, 'playerDiagonalWalking') end}, + {'findPath', function() + initPlayer() + testing.runLocalTest(player, 'findPath') + end}, + {'findRandomPointAroundCircle', function() + initPlayer() + testing.runLocalTest(player, 'findRandomPointAroundCircle') + end}, + {'castNavigationRay', function() + initPlayer() + testing.runLocalTest(player, 'castNavigationRay') + end}, {'teleport', testTeleport}, } @@ -80,4 +92,3 @@ return { }, eventHandlers = testing.eventHandlers, } - diff --git a/scripts/data/integration_tests/test_lua_api/testing_util.lua b/scripts/data/integration_tests/test_lua_api/testing_util.lua index 5ce5b2ff26..c3ab7b319e 100644 --- a/scripts/data/integration_tests/test_lua_api/testing_util.lua +++ b/scripts/data/integration_tests/test_lua_api/testing_util.lua @@ -58,6 +58,24 @@ function M.expectGreaterOrEqual(v1, v2, msg) end end +function M.expectGreaterThan(v1, v2, msg) + if not (v1 > v2) then + error(string.format('%s: %s > %s', msg or '', v1, v2), 2) + end +end + +function M.expectLessOrEqual(v1, v2, msg) + if not (v1 <= v2) then + error(string.format('%s: %s <= %s', msg or '', v1, v2), 2) + end +end + +function M.expectEqual(v1, v2, msg) + if not (v1 == v2) then + error(string.format('%s: %s ~= %s', msg or '', v1, v2), 2) + end +end + local localTests = {} local localTestRunner = nil @@ -96,4 +114,3 @@ M.eventHandlers = { } return M -