diff --git a/.gitignore b/.gitignore index 43a6f17..1521506 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ luac.out /robase-venv # JUnit test report -/testReport.xml +/junit-report*.xml node_modules diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..aa871ef --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "modules/lemur"] + path = modules/lemur + url = https://github.com/CodeKingdomsTeam/lemur.git +[submodule "modules/Utils"] + path = modules/Utils + url = https://github.com/CodeKingdomsTeam/lua-utils +[submodule "modules/lua-fsm"] + path = modules/lua-fsm + url = https://github.com/unindented/lua-fsm diff --git a/.luacheckrc b/.luacheckrc index 4cf3372..0955f8d 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,3 +1,7 @@ +exclude_files = { + ".luacheckrc" +} + stds.roblox = { read_globals = { -- global functions @@ -18,6 +22,7 @@ stds.roblox = { "shared", "workspace", "plugin", + "ypcall", -- types "Axes", "BrickColor", @@ -63,6 +68,10 @@ stds.roblox = { } } +-- Does not correctly detect usage of ... +files["lib/Utils.lua"] = {ignore = {"212"}} +files["spec/Utils_spec.lua"] = {ignore = {"212"}} + std = "lua51+roblox" files["spec/*.lua"] = { diff --git a/Jenkinsfile b/Jenkinsfile index 1d92366..a9d95c1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -44,7 +44,12 @@ pipeline { stage('Tests') { steps { - sh 'rm -f testReport.xml cobertura.xml && ./test.sh --verbose --coverage --output junit > testReport.xml && ./lua_install/bin/luacov-cobertura -o cobertura.xml' + sh ''' + rm -f luacov.stats.* luacov.report.* junit-report.xml cobertura.xml && \ + ./test.sh --verbose --coverage --output junit -Xoutput junit-report.xml && \ + sed -i '/^\\s*$/d' junit-report.xml && \ + ./lua_install/bin/luacov-cobertura -o cobertura.xml + ''' } post { failure { @@ -79,7 +84,7 @@ pipeline { post { always { - junit "testReport.xml" + junit "junit-report.xml" cobertura coberturaReportFile: 'cobertura.xml' } failure { @@ -93,4 +98,4 @@ pipeline { } } -} \ No newline at end of file +} diff --git a/lib/Hsm.lua b/lib/Hsm.lua new file mode 100644 index 0000000..682d1e7 --- /dev/null +++ b/lib/Hsm.lua @@ -0,0 +1,119 @@ +local Fsm = require(script.Parent.Fsm) + +local Hsm = {} + +function Hsm.new() + local hsm = {} + hsm.fsms = {} + + local function hsmEvent(event) + local handlingFsmIndex = nil + for i = #hsm.fsms, 1, -1 do + if hsm.fsms[i][event] then + handlingFsmIndex = i + end + end + if handlingFsmIndex == nil then + -- There is no such action on any fsm, so return nil. + return nil + else + return function(...) + -- Remove nested FSMs we won't need now. + for i = #hsm.fsms, handlingFsmIndex + 1, -1 do + -- Call the on_leave_ function for the current state, if there is one. + local fsm = hsm.fsms[i] + if fsm.current and fsm["on_leave_" .. fsm.current] then + fsm["on_leave_" .. fsm.current](fsm, event, fsm.current, "none", ...) + end + + table.remove(hsm.fsms) + end + + -- Call the event function on the FSM that can handle it. + local handlingFsm = hsm.fsms[handlingFsmIndex] + handlingFsm[event](...) + end + end + end + + setmetatable( + hsm, + { + __index = function(_, key) + return Hsm[key] or hsmEvent(key) + end + } + ) + + return hsm +end + +-- Adds an FSM representing a substate to the HSM's stack of FSMs. +function Hsm:pushFsm(fsmConfig) + -- Defer the initial state until the FSM is added to the HSM. + local initial = fsmConfig.initial + if type(initial) == "string" then + initial = {state = initial} + elseif not initial then + initial = {state = "none"} + else + assert(type(initial) == "table") + end + initial.event = "init" + initial.defer = true + fsmConfig.initial = initial + + local fsm = Fsm.create(fsmConfig) + table.insert(self.fsms, fsm) + + -- Call the init event created above to trigger the transition to the initial state. + fsm.init() +end + +-- Returns an array of the current substates for the HSM, starting with +-- the root FSM's substate. +function Hsm:current() + local currentStates = {} + for _, fsm in ipairs(self.fsms) do + table.insert(currentStates, fsm.current) + end + return currentStates +end + +-- Returns whether the HSM has the given state for one of +-- its substates. +function Hsm:is(state) + for _, fsm in ipairs(self.fsms) do + if fsm.is(state) then + return true + end + end + return false +end + +-- Returns whether the HSM can handle the given event. +function Hsm:can(event) + for _, fsm in ipairs(self.fsms) do + if fsm[event] and fsm.can(event) then + return true + end + end + return false +end + +function Hsm:cannot(event) + return not self:can(event) +end + +-- Returns an array of all of the allowed transitions for the HSM. +function Hsm:transitions() + local transitions = {} + for _, fsm in ipairs(self.fsms) do + for transition in ipairs(fsm.transitions()) do + table.insert(transitions, transition) + end + end + return transitions +end + +return Hsm diff --git a/lib/Logger.lua b/lib/Logger.lua new file mode 100644 index 0000000..f68c32e --- /dev/null +++ b/lib/Logger.lua @@ -0,0 +1,32 @@ +local Logger = {} + +Logger.LOG_LEVEL = { + AUTO = "Auto", + TRACE = "Trace", + DEBUG = "Debug", + LOG = "Log", + WARN = "Warn", + ERROR = "Error" +} + +function Logger.Raise(level, ...) --: string, ...any => void + print("[", level, "] ", ...) +end + +function Logger.Trace(...) --: ...any => void + Logger.Raise("Trace", ...) +end +function Logger.Debug(...) --: ...any => void + Logger.Raise("Debug", ...) +end +function Logger.Log(...) --: ...any => void + Logger.Raise("Log", ...) +end +function Logger.Warn(...) --: ...any => void + Logger.Raise("Warn", ...) +end +function Logger.Error(...) --: ...any => void + Logger.Raise("Error", ...) +end + +return Logger diff --git a/lib/ProjectileService.lua b/lib/ProjectileService.lua new file mode 100644 index 0000000..61ed4ec --- /dev/null +++ b/lib/ProjectileService.lua @@ -0,0 +1,113 @@ +local RunService = game:GetService("RunService") +local Logger = require(script.Parent.Logger) + +local function cloneFromServerStorage(projectileName) + local projectilePrefab = game.ServerStorage:FindFirstChild(projectileName) + + if typeof(projectilePrefab) ~= "Instance" then + Logger.Error("Could not find projectile part " .. projectileName) + elseif projectilePrefab.ClassName ~= "Part" then + Logger.Error("Projectile " .. projectileName .. " is not a Part") + else + return projectilePrefab:Clone() + end +end + +local ProjectileService = { + MAX_FLIGHT_TIME = 10 +} + +function ProjectileService:FireProjectile(projectileName, from, velocity, options) + options = options or {} + local position = from + local ignoreList = options.ignoreList or {} + + local gravity = game.Workspace.Gravity + if typeof(options.gravityMultiplier) == "number" then + gravity = gravity * options.gravityMultiplier + end + + local ignoreWater = false + if typeof(options.ignoreWater) == "boolean" then + ignoreWater = options.ignoreWater + end + + local projectile = cloneFromServerStorage(projectileName) + if not projectile then + -- The projectile failed to be cloned, so exit. + return + end + + local baseRotation = projectile.CFrame - projectile.Position + projectile.CFrame = CFrame.new(position, position + velocity) * baseRotation + projectile.Parent = game.Workspace + + -- Remove the projectile from the control of the physics engine by anchoring it, + -- as its path will be controlled by this service. + projectile.Anchored = true + projectile.CanCollide = false + + -- The connection used for the stepped update of the projectile. + local steppedUpdate + local startTime = tick() + local lastStepTime = startTime + + local function updateProjectile() + local currentTime = tick() + local totalFlightTime = currentTime - startTime + if totalFlightTime > self.MAX_FLIGHT_TIME then + -- The projectile has been in flight for longer than the maximum time, so + -- something has probably gone wrong. Remove it from the control of this + -- service. + projectile.Anchored = false + steppedUpdate:Disconnect() + return + end + local timeDelta = currentTime - lastStepTime + local bulletRay = Ray.new(position, velocity * timeDelta) + local hitPart, hitPosition, hitNormal = + workspace:FindPartOnRayWithIgnoreList(bulletRay, ignoreList, false, ignoreWater) + if hitPart then + -- The projectile has hit something. Move it to the point of collision, stop this service from + -- updating it any more and fire the projectile's Hit event if it has one. + projectile.CFrame = CFrame.new(hitPosition, hitPosition + velocity) * baseRotation + steppedUpdate:Disconnect() + local hitEvent = projectile:FindFirstChild("Hit") + if typeof(hitEvent) == "Instance" and hitEvent.ClassName == "BindableEvent" then + hitEvent:Fire(hitPart, hitPosition, hitNormal, velocity) + end + else + projectile.CFrame = CFrame.new(position, position + velocity) * baseRotation + -- Apply acceleration due to gravity. + velocity = velocity - (Vector3.new(0, gravity, 0) * timeDelta) + position = position + velocity * timeDelta + end + lastStepTime = currentTime + end + + updateProjectile() + steppedUpdate = RunService.Stepped:Connect(updateProjectile) + + return projectile, steppedUpdate +end + +function ProjectileService:ConnectToEvent(eventName) + if not eventName then + eventName = "FireProjectile" + end + + local fireProjectileEvent = game.ReplicatedStorage:FindFirstChild(eventName) + if not fireProjectileEvent then + fireProjectileEvent = Instance.new("RemoteEvent") + fireProjectileEvent.Name = eventName + fireProjectileEvent.Parent = game.ReplicatedStorage + end + + return fireProjectileEvent.OnServerEvent:Connect( + function(_, ...) + self:FireProjectile(...) + end + ) +end + +return ProjectileService diff --git a/lib/Utils.lua b/lib/Utils.lua new file mode 100644 index 0000000..f8c3089 --- /dev/null +++ b/lib/Utils.lua @@ -0,0 +1,34 @@ +local Utils = {} + +--[[ + Creates a debounced function that delays invoking fn until after secondsDelay seconds have elapsed since the last time the debounced function was invoked. +]] +function Utils.Debounce(fn, secondsDelay) + assert(type(fn) == "function" or (type(fn) == "table" and getmetatable(fn) and getmetatable(fn).__call ~= nil)) + assert(type(secondsDelay) == "number") + + local lastInvocation = 0 + local lastResult = nil + + return function(...) + local args = {...} + + lastInvocation = lastInvocation + 1 + + local thisInvocation = lastInvocation + delay( + secondsDelay, + function() + if thisInvocation ~= lastInvocation then + return + end + + lastResult = fn(unpack(args)) + end + ) + + return lastResult + end +end + +return Utils diff --git a/lib/init.lua b/lib/init.lua new file mode 100644 index 0000000..e69de29 diff --git a/modules/Utils b/modules/Utils new file mode 160000 index 0000000..7e70389 --- /dev/null +++ b/modules/Utils @@ -0,0 +1 @@ +Subproject commit 7e70389d9783fb288c1661d0b79d4eb8f90ae11c diff --git a/modules/lemur b/modules/lemur new file mode 160000 index 0000000..fceff79 --- /dev/null +++ b/modules/lemur @@ -0,0 +1 @@ +Subproject commit fceff7943d2e4f08bf3022dd611b0c5e43599f43 diff --git a/modules/lua-fsm b/modules/lua-fsm new file mode 160000 index 0000000..4cca103 --- /dev/null +++ b/modules/lua-fsm @@ -0,0 +1 @@ +Subproject commit 4cca1037ec916049fd9ee57c3871a8d75f4ebda4 diff --git a/setup.sh b/setup.sh index 8eec5f8..a32f575 100755 --- a/setup.sh +++ b/setup.sh @@ -6,6 +6,8 @@ set -o pipefail # Inspiration from https://github.com/LPGhatguy/lemur/blob/master/.travis.yml +git submodule update --init --recursive + yarn install --frozen-lockfile --non-interactive export LUA="lua=5.1" @@ -25,7 +27,7 @@ hererocks lua_install -r^ --$LUA export PATH="$PWD/lua_install/bin:$PATH" -ROCKS=('busted 2.0.rc12-1' 'luacov 0.13.0-1' 'luacov-console 1.1.0-1' 'luacov-cobertura 0.2-1' 'luacheck 0.22.1-1') +ROCKS=('busted 2.0.rc12-1' 'luacov 0.13.0-1' 'luacov-console 1.1.0-1' 'luacov-cobertura 0.2-1' 'luacheck 0.22.1-1' 'luafilesystem 1.7.0-2' 'luasocket 3.0rc1-2') for ROCK in "${ROCKS[@]}" do diff --git a/spec-source/LemurUtils.lua b/spec-source/LemurUtils.lua new file mode 100644 index 0000000..6fe0c9e --- /dev/null +++ b/spec-source/LemurUtils.lua @@ -0,0 +1,16 @@ +local LemurUtils = {} + +function LemurUtils.LoadRobase(habitat) + local ReplicatedStorage = habitat.game:GetService("ReplicatedStorage") + + local robase = habitat:loadFromFs("lib") + robase.Parent = ReplicatedStorage + + local luafsm = habitat:loadFromFs("modules/lua-fsm/src/fsm.lua") + luafsm.Name = "Fsm" + luafsm.Parent = robase + + return robase +end + +return LemurUtils diff --git a/spec/Hsm_spec.lua b/spec/Hsm_spec.lua new file mode 100644 index 0000000..9270f03 --- /dev/null +++ b/spec/Hsm_spec.lua @@ -0,0 +1,278 @@ +local lemur = require("modules.lemur.lib") +local LemurUtils = require("spec-source.LemurUtils") + +describe( + "Hsm", + function() + local habitat + local robase + local Hsm + before_each( + function() + habitat = lemur.Habitat.new() + robase = LemurUtils.LoadRobase(habitat) + Hsm = habitat:require(robase.Hsm) + end + ) + describe( + "new", + function() + it( + "should instantiate a new HSM consisting of no FSMs", + function() + assert(Hsm) + local hsm = Hsm.new() + assert.not_nil(hsm) + assert.are.same({}, hsm.fsms) + end + ) + end + ) + describe( + "pushFsm", + function() + -- Create an HSM consisting of an FSM with a nested FSM. + -- Assert that both FSMs are added to the HSM and that the + -- on_enter callbacks are called at the right time. + local function testHsmWithInitialStates(initialStates) + local hsm = Hsm.new() + local output = {} + hsm:pushFsm( + { + initial = initialStates[1], + callbacks = { + on_enter_A = function() + table.insert(output, "A1") + hsm:pushFsm( + { + initial = initialStates[2], + callbacks = { + on_enter_B = function() + table.insert(output, "B") + end + } + } + ) + table.insert(output, "A2") + end + } + } + ) + assert.equal(2, #hsm.fsms) + assert.are.same({"A1", "B", "A2"}, output) + end + + it( + "should add a new FSM to the HSM and move it to its initial state when it is a string", + function() + testHsmWithInitialStates({"A", "B"}) + end + ) + it( + "should add a new FSM to the HSM and move it to its initial state when it is a table", + function() + testHsmWithInitialStates({{state = "A"}, {state = "B"}}) + end + ) + it( + "should throw if an FSM's initial state is neither a string nor a table", + function() + local hsm = Hsm.new() + assert.has.errors( + function() + hsm:pushFsm( + { + initial = 123 + } + ) + end + ) + end + ) + end + ) + local function createHsm() + local hsm = Hsm.new() + local function onEnterB() + hsm:pushFsm( + { + initial = "C" + } + ) + end + local function onEnterA() + hsm:pushFsm( + { + initial = "B", + callbacks = { + on_enter_B = onEnterB + } + } + ) + end + hsm:pushFsm( + { + initial = "A", + callbacks = { + on_enter_A = onEnterA + } + } + ) + return hsm + end + describe( + "current", + function() + it( + 'should return an array containing "none" if there is no initial state for the HSM', + function() + local hsm = Hsm.new() + hsm:pushFsm({}) + assert.are.same({"none"}, hsm:current()) + end + ) + it( + "should return an array of states of each of the FSMs in the current state", + function() + local hsm = createHsm() + assert.are.same({"A", "B", "C"}, hsm:current()) + end + ) + end + ) + describe( + "is", + function() + it( + "returns false if the given state is not the state of one of the nested FSMs", + function() + local hsm = createHsm() + assert.is_false(hsm:is("D")) + end + ) + it( + "returns true if the given state is the state of one of the nested FSMs", + function() + local hsm = createHsm() + assert.is_true(hsm:is("A")) + assert.is_true(hsm:is("B")) + assert.is_true(hsm:is("C")) + end + ) + end + ) + describe( + "can", + function() + it( + "returns false if the given transition is not possible for any of the FSMs", + function() + local hsm = Hsm.new() + local onEnterA = + hsm:pushFsm( + { + initial = "C", + events = { + {name = "dToC", from = "D", to = "C"} + } + } + ) + hsm:pushFsm( + { + initial = "A", + events = { + {name = "aToB", from = "A", to = "B"} + }, + callbacks = { + on_enter_A = onEnterA + } + } + ) + assert.is_false(hsm:can("dToC")) + assert.is_true(hsm:cannot("dToC")) + end + ) + it( + "returns true if the given transition is possible for at least one of the FSMs", + function() + local hsm = Hsm.new() + local onEnterA = + hsm:pushFsm( + { + initial = "C", + events = { + {name = "cToD", from = "C", to = "D"} + } + } + ) + hsm:pushFsm( + { + initial = "A", + events = { + {name = "aToB", from = "A", to = "B"} + }, + callbacks = { + on_enter_A = onEnterA + } + } + ) + assert.is_true(hsm:can("cToD")) + assert.is_false(hsm:cannot("cToD")) + end + ) + end + ) + + describe( + "events", + function() + it( + "should call the on_leave callbacks of a nested FSM's state when leaving the substate using that FSM", + function() + local hsm = Hsm.new() + local outputs = {} + local onEnterC = function() + hsm:pushFsm( + { + initial = "D", + callbacks = { + on_leave_D = function() + table.insert(outputs, "D") + end + } + } + ) + end + local onEnterA = function() + hsm:pushFsm( + { + initial = "C", + callbacks = { + on_enter_C = onEnterC, + on_leave_C = function() + table.insert(outputs, "C") + end + } + } + ) + end + hsm:pushFsm( + { + initial = "A", + events = { + {name = "aToB", from = "A", to = "B"} + }, + callbacks = { + on_enter_A = onEnterA + } + } + ) + hsm:aToB() + assert(hsm:is("B")) + assert.are.same({"D", "C"}, outputs) + end + ) + end + ) + end +) diff --git a/spec/ProjectileService_spec.lua b/spec/ProjectileService_spec.lua new file mode 100644 index 0000000..3cb2d7f --- /dev/null +++ b/spec/ProjectileService_spec.lua @@ -0,0 +1,72 @@ +local lemur = require("modules.lemur.lib") +local LemurUtils = require("spec-source.LemurUtils") + +describe( + "ProjectileService", + function() + local habitat + local robase + local Vector3 + local ProjectileService + before_each( + function() + habitat = lemur.Habitat.new() + robase = LemurUtils.LoadRobase(habitat) + Vector3 = habitat.environment.Vector3 + ProjectileService = habitat:require(robase.ProjectileService) + end + ) + describe( + "ConnectToEvent", + function() + local fireProjectileStub + before_each( + function() + fireProjectileStub = stub.new(ProjectileService, "FireProjectile") + end + ) + after_each( + function() + fireProjectileStub:revert() + end + ) + it( + "should create a remote event and connect to it its FireProjectile function", + function() + -- Assert that a remote event has been created. + local connection = ProjectileService:ConnectToEvent() + local fireProjectileEvent = habitat.game:GetService("ReplicatedStorage"):FindFirstChild("FireProjectile") + assert.not_nil(fireProjectileEvent) + assert.equals(fireProjectileEvent.ClassName, "RemoteEvent") + + -- Fire the event on the server and assert that the FireProjectile stub was called with the same arguments. + local projectileName = "Arrow" + local spawnPosition = Vector3.new(1, 1, 1) + fireProjectileEvent:FireServer(projectileName, spawnPosition) + assert.spy(fireProjectileStub).was_called(1) + assert.spy(fireProjectileStub).was_called_with(ProjectileService, projectileName, spawnPosition) + + -- Disconnect the ProjectileService from the remote event and then assert that the stub does not get called + -- when the event is fired again. + assert.not_nil(connection) + connection:Disconnect() + fireProjectileStub:clear() + fireProjectileEvent:FireServer("Bullet", spawnPosition) + assert.spy(fireProjectileStub).was_not_called() + end + ) + end + ) + describe( + "FireProjectile", + function() + pending( + "should clone a Part by name from ServerStorage and connect a stepped update to control its flight path", + function() + -- TODO: Implement once lemur has CFrame, Part, Clone etc. + end + ) + end + ) + end +) diff --git a/spec/Utils_spec.lua b/spec/Utils_spec.lua new file mode 100644 index 0000000..a2b1721 --- /dev/null +++ b/spec/Utils_spec.lua @@ -0,0 +1,99 @@ +local lemur = require("modules.lemur.lib") +local Utils = require("Utils") + +describe( + "Utils", + function() + describe( + "Debounce", + function() + local habitat + local scheduler + local callSpy + local debounced + local oldDelay + + before_each( + function() + habitat = lemur.Habitat.new() + scheduler = habitat.taskScheduler + + oldDelay = _G.delay + _G.delay = habitat.environment.delay + + callSpy = + spy.new( + function(...) + local printResult = "" + + for _, v in ipairs(arg) do + printResult = printResult .. tostring(v) .. "\t" + end + printResult = printResult .. "\n" + + print("Called with " .. printResult) + + return arg + end + ) + + debounced = Utils.Debounce(callSpy, 100) + end + ) + + after_each( + function() + _G.delay = oldDelay + end + ) + + it( + "should not call before the delay has elapsed", + function() + debounced("hi") + + assert.spy(callSpy).was_not_called() + + scheduler:step(99) + + assert.spy(callSpy).was_not_called() + end + ) + + it( + "should call after the delay", + function() + debounced("hi") + + scheduler:step(100) + + assert.spy(callSpy).was_called(1) + assert.spy(callSpy).was_called_with("hi") + end + ) + + it( + "should group calls and call the debounced function with the last arguments", + function() + local result = debounced("hi") + + assert.are.same(result, nil) + + local result2 = debounced("guys") + + assert.are.same(result2, nil) + + scheduler:step(100) + + assert.spy(callSpy).was_called(1) + assert.spy(callSpy).was_called_with("guys") + + local result3 = debounced("stuff") + + assert.are.same(result3, {[1] = "guys", n = 1}) + end + ) + end + ) + end +) diff --git a/test.sh b/test.sh index ea6ef9e..ec982da 100755 --- a/test.sh +++ b/test.sh @@ -6,4 +6,4 @@ set -o pipefail export PATH="$PWD/lua_install/bin:$PATH" -busted -m './lib/?.lua' spec "$@" \ No newline at end of file +busted -m './lib/?.lua' -m './?/init.lua' spec "$@" \ No newline at end of file