This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,5 @@
# Generated by Rotriever. Format subject to change in future releases.
name = "roblox/state-table"
version = "0.1.0"
commit = "aa0641127efcb15fcabb26f5ba46e44815f42d4a"
source = "url+https://github.com/roblox/state-table"
@@ -0,0 +1,202 @@
local StateTable = {}
StateTable.__index = StateTable
local function validateStateTableItem(item, qualifier)
local type = typeof(item)
local isValid = type == "string" or type == "userdata"
assert(isValid, string.format("%s '%s' must be a string or userdata but is a %s", qualifier, tostring(item), type))
end
--[[
This class method creates a new StateTable instance that you can use to control complicated
logic that is based upon your state machine design. Ex:
self.stateTable = StateTable.new(name, initialState, initialContext, {
InitialState = {
EventName1 = { nextState = "StateOne", action = self.actionDoSomething },
EventName2 = { nextState = "FinalState" } -- actions are optional
},
StateOne = {
EventName1 = { nextState = "FinalState", action = self.actionDoSomethingAtLast },
EventName3 = { action = self.actionDoSomethingElse } -- will maintain current state
},
FinalState = {} -- transitions are optional
})
Arguments:
name - A debug name for this StateTable. (String)
initialState - Name of the beginning state for this StateTable. (String)
initialContext - A reference to an existing table where you hold all sidecar contextual data
that needs to be manipulated by this StateTable's actions. (Table)
transitionTable - Description of the state machine structure. (Table)
The outermost keys in "transitionTable" represent individual states in your design, each of which
contains a description of the events that can be called while in that state. Calling an event
triggers a transition to a new state while also (optionally) running an action functor.
(All states and events must be simple strings or userdata.)
(If using userdata for states and events, implement a tostring metamethod for ease of debugging.)
Named events in your state table will be converted into functions that you can call directly.
Calling these event functions will transition the StateTable to the appropriate nextState and
call the registered action handler, if any. You may pass arguments to your actions through the
event function by passing them as a table. Ex:
self.stateTable.events.EventName1(args)
The combination of named states and events in StateTable make up the control flow portion of your
state machine. To run business logic, you need to implement Actions.
Each action functor accepts four arguments: the current state, the next state, event arguments,
and the contextual data table that you passed in when you called the event function. Your action
functor should return a table containing the keys that need to be updated from currentContext.
The returned table will be merged into currentContext and passed back to your onStateChange
callback. Ex:
function actionDoSomething(currentState, nextState, args, currentContext)
local contextDiff = doSomething(args, currentContext)
return contextDiff
end
Do NOT update your own copy of the StateTable's internal state variable or your context in actions.
If this is an action-less transition, you'll fail to update it!
To update your context and own tracking of the current state at the same time, listen to changes
via StateTable:onStateChange. See the documentation of that method for more details.
]]
function StateTable.new(name, initialState, initialContext, transitionTable)
assert(typeof(name) == "string", "name must be a string")
assert(#name > 0, "name must not be an empty string")
validateStateTableItem(initialState, "initialState")
assert(initialContext == nil or typeof(initialContext) == "table", "initialContext must be a table or nil")
assert(typeof(transitionTable) == "table", "transitionTable must be a table")
assert(typeof(transitionTable[initialState]) == "table", "initialState must be present in transitionTable")
local self = {}
setmetatable(self, StateTable)
self.name = name
self.currentState = initialState
self.currentContext = initialContext or {}
self.transitionTable = {}
self.events = {}
for state, eventTable in pairs(transitionTable) do
validateStateTableItem(state, "state")
assert(typeof(eventTable) == "table", string.format("state '%s' must map to a table", tostring(state)))
local parsedEventTable = {}
for event, eventData in pairs(eventTable) do
validateStateTableItem(event, "event")
assert(typeof(eventData) == "table", string.format("event '%s' must map to a table", tostring(event)))
local nextState = eventData.nextState
local action = eventData.action
if nextState ~= nil then
validateStateTableItem(nextState, "nextState")
-- Check that the transition lands on a known state
assert(transitionTable[nextState] ~= nil,
string.format("nextState '%s' does not exist in transitionTable", tostring(nextState)))
end
assert(action == nil or typeof(action) == "function", "action must be a function")
parsedEventTable[event] = eventData
-- Create a function to make it easy to call this event
if self.events[event] == nil then
self.events[event] = function(args)
return self:handleEvent(event, args)
end
end
end
self.transitionTable[state] = parsedEventTable
end
-- catch calls to invalid events earlier
setmetatable(self.events, {
__index = function(_, event)
error(string.format("'%s' is not a valid event in StateTable '%s'",
tostring(event), self.name), 2)
end
})
return self
end
--[[
It is recommended that you use the auto-generated event functions instead
of calling this method; see StateTable.new.
Process an event through this StateTable instance. Pass in the name
of the event, and optional arguments. The arguments will be passed to the
registered action handler for the state/event transition, if any.
This function does not return anything. Listen to changes using
StateTable:onStateChange if you need to store the current state or use the
results of an action.
]]
function StateTable:handleEvent(event, args)
validateStateTableItem(event, "event")
assert(args == nil or typeof(args) == "table", "args must be nil or valid table")
local currentState = self.currentState
local eventMap = self.transitionTable[currentState]
assert(eventMap ~= nil, "no transition events for current state")
if eventMap[event] ~= nil then
local eventData = eventMap[event]
local nextState = eventData.nextState or currentState
local action = eventData.action
local updatedContext = self.currentContext
if action ~= nil then
local contextDiff = action(currentState, nextState, args, self.currentContext) or {}
-- Immutable merge dictionaries (without Cryo)
updatedContext = {}
for key, value in pairs(self.currentContext) do
updatedContext[key] = value
end
for key, value in pairs(contextDiff) do
updatedContext[key] = value
end
self.currentContext = updatedContext
end
self.currentState = nextState
if self.stateChangeHandler ~= nil then
self.stateChangeHandler(currentState, nextState, updatedContext)
end
end
end
--[[
Register a function to process changes in state. Your function should have
the following signature and return nothing:
function handleStateChange(oldState, newState, updatedContext)
self.currentState = newState
self.currentContext = updatedContext
end
The updatedContext parameter contains the table that was returned by the action
handler associated with the event transition.
]]
function StateTable:onStateChange(stateChangeHandler)
assert(stateChangeHandler == nil or typeof(stateChangeHandler) == "function",
"stateChangeHandler must be nil or a function")
self.stateChangeHandler = stateChangeHandler
end
return StateTable
@@ -0,0 +1,525 @@
return function()
local StateTable = require(script.Parent.StateTable)
local TEST_NAME = "test_state_table_name"
local TEST_INITIAL_STATE = "Initial"
local TEST_DATA = { foo = 1 }
local DUMMY_ACTION = function(_, _, data)
return data
end
local function FieldCount(t)
local fieldCount = 0
for _ in pairs(t) do
fieldCount = fieldCount + 1
end
return fieldCount
end
local function ShallowEqual(A, B)
if not A or not B then
return false
elseif A == B then
return true
end
for key, value in pairs(A) do
if B[key] ~= value then
return false
end
end
for key, value in pairs(B) do
if A[key] ~= value then
return false
end
end
return true
end
describe("StateTable.new validation throw tests", function()
it("should throw if name is nil", function()
expect(function()
StateTable.new(nil, TEST_INITIAL_STATE, {}, { Initial = {} })
end).to.throw()
end)
it("should throw if name is not a string", function()
expect(function()
StateTable.new(5, TEST_INITIAL_STATE, {}, { Initial = {} })
end).to.throw()
end)
it("should throw if name is empty", function()
expect(function()
StateTable.new("", TEST_INITIAL_STATE, {}, { Initial = {} })
end).to.throw()
end)
it("should throw if initialState is nil", function()
expect(function()
StateTable.new(TEST_NAME, nil, {}, { Initial = {} })
end).to.throw()
end)
it("should throw if initialState is not a string", function()
expect(function()
StateTable.new(TEST_NAME, 5, {}, { Initial = {} })
end).to.throw()
end)
it("should throw if initialState is empty", function()
expect(function()
StateTable.new(TEST_NAME, "", {}, { Initial = {} })
end).to.throw()
end)
it("should throw if initialContext is wrong type", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, 5, { Initial = {} })
end).to.throw()
end)
it("should throw if transitionTable is nil", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, nil)
end).to.throw()
end)
it("should throw if empty transitionTable is provided", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {})
end).to.throw()
end)
it("should throw if non-table transitionTable is provided", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, 1)
end).to.throw()
end)
it("should throw if no arguments are provided", function()
expect(function()
StateTable.new()
end).to.throw()
end)
it("should throw if non-string/userdata used for state name", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { [1] = {} })
end).to.throw()
end)
it("should throw if non-table is used for state table", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = 1 })
end).to.throw()
end)
it("should throw if non-string/userdata is used for event name", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { [1] = {} } })
end).to.throw()
end)
it("should throw if non-table is used for event data table", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = 1 } })
end).to.throw()
end)
it("should throw if non-string/userdata used for nextState", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { nextState = 1 } } })
end).to.throw()
end)
it("should throw if non-function used for action", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { action = 1 } } })
end).to.throw()
end)
it("should throw if initial state not in transitionTable", function()
expect(function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { NotInitial = {} })
end).to.throw()
end)
end)
describe("StateTable.new validation success tests", function()
it("should not throw if empty event table is provided", function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = {} })
end)
it("should not throw if empty event data table is provided", function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = {} } })
end)
it("should not throw if action is missing", function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Next" }
},
Next = {}
})
end)
it("should not throw if nextState is missing", function()
StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { action = function() end } } })
end)
end)
describe("StateTable.new event function creation tests", function()
it("should create an event function for a single event", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { } } })
expect(FieldCount(st.events)).to.equal(1)
expect(typeof(st.events.Event1)).to.equal("function")
end)
it("should create different event functions for multiple events", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { }, Event2 = { } } })
expect(FieldCount(st.events)).to.equal(2)
expect(typeof(st.events.Event1)).to.equal("function")
expect(typeof(st.events.Event2)).to.equal("function")
end)
it("should not create event functions without at least one event", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = {} })
expect(FieldCount(st.events)).to.equal(0)
end)
end)
describe("StateTable onStateChange registration tests", function()
it("should assert when non-function provided", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = {} })
expect(function()
st:onStateChange(5)
end).to.throw()
end)
it("should not assert when function provided", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = {} })
st:onStateChange(function() end)
end)
it("should not assert when nil provided", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = {} })
st:onStateChange(nil)
end)
it("should call onStateChange handler when transition occurs", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = {}
}
})
local called = false
st:onStateChange(function()
called = true
end)
st.events.Event1(nil)
expect(called).to.equal(true)
end)
it("should not call onStateChange handler after it has been de-registered", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = {}
}
})
local called = false
st:onStateChange(function()
called = true
end)
st:onStateChange(nil)
st.events.Event1(nil)
expect(called).to.equal(false)
end)
end)
describe("StateTable event call tests", function()
it("should change state when handleEvent is called", function()
local action1Called = false
local testAction1 = function() action1Called = true end
local action2Called = false
local testAction2 = function() action2Called = true end
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Two", action = testAction1 }
},
Two = {
Event2 = { action = testAction2 }
}
})
st:handleEvent("Event1", nil)
expect(action1Called).to.equal(true)
expect(action2Called).to.equal(false)
action1Called = false
action2Called = false
st:handleEvent("Event2", nil)
expect(action1Called).to.equal(false)
expect(action2Called).to.equal(true)
end)
it("should call mapped action when handleEvent is called", function()
local actionOldState, actionNewState, actionData
local testAction = function(oldState, newState, data)
actionOldState = oldState
actionNewState = newState
actionData = data
end
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Two", action = testAction }
},
Two = {}
})
st:handleEvent("Event1", TEST_DATA)
expect(actionOldState).to.equal("Initial")
expect(actionNewState).to.equal("Two")
expect(ShallowEqual(actionData, TEST_DATA)).to.equal(true)
end)
it("should return expected nextState and data in onStateChange callback when handleEvent is called", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Two", action = DUMMY_ACTION }
},
Two = {}
})
local oldState, newState, updatedContext
st:onStateChange(function(os, ns, uc)
oldState = os
newState = ns
updatedContext = uc
end)
st:handleEvent("Event1", TEST_DATA)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Two")
expect(ShallowEqual(updatedContext, TEST_DATA)).to.equal(true)
end)
it("should call mapped action when event functor is called", function()
local actionOldState, actionNewState, actionData
local testAction = function(oldState, newState, data)
actionOldState = oldState
actionNewState = newState
actionData = data
end
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Two", action = testAction }
},
Two = {}
})
st.events.Event1(TEST_DATA)
expect(actionOldState).to.equal("Initial")
expect(actionNewState).to.equal("Two")
expect(ShallowEqual(actionData, TEST_DATA)).to.equal(true)
end)
it("should return expected nextState and data in onStateChange callback when event functor is called", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Two", action = DUMMY_ACTION }
},
Two = {}
})
local oldState, newState, updatedContext
st:onStateChange(function(os, ns, uc)
oldState = os
newState = ns
updatedContext = uc
end)
st.events.Event1(TEST_DATA)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Two")
expect(ShallowEqual(updatedContext, TEST_DATA)).to.equal(true)
end)
it("should still return nextState in onStateChange callback when no action handler is provided", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { nextState = "Two" }
},
Two = {}
})
local oldState, newState, updatedContext
st:onStateChange(function(os, ns, uc)
oldState = os
newState = ns
updatedContext = uc
end)
st.events.Event1(TEST_DATA)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Two")
expect(typeof(updatedContext)).to.equal("table")
expect(FieldCount(updatedContext)).to.equal(0)
end)
it("should return current state and empty data if new state and action handler are not specified", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { } } })
local oldState, newState, updatedContext
st:onStateChange(function(os, ns, uc)
oldState = os
newState = ns
updatedContext = uc
end)
st.events.Event1(TEST_DATA)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Initial")
expect(FieldCount(updatedContext)).to.equal(0)
end)
it("should return current state and matching data if only action handler is provided", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = { action = DUMMY_ACTION } } })
local oldState, newState, updatedContext
st:onStateChange(function(os, ns, uc)
oldState = os
newState = ns
updatedContext = uc
end)
st.events.Event1(TEST_DATA)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Initial")
expect(ShallowEqual(updatedContext, TEST_DATA)).to.equal(true)
end)
it("should return empty data in onStateChange callback when nil data is provided to no-action event", function()
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, { Initial = { Event1 = {} } })
local updatedContext
st:onStateChange(function(_, _, uc)
updatedContext = uc
end)
st.events.Event1(nil)
expect(FieldCount(updatedContext)).to.equal(0)
end)
it("should merge context updates with old context", function()
local initialContext = { foo = 1 }
local function action1()
return { bar = 2 }
end
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, initialContext, {
Initial = {
Event1 = { action = action1 }
}
})
local updatedContext
st:onStateChange(function(_, _, uc)
updatedContext = uc
end)
st.events.Event1()
expect(ShallowEqual(updatedContext, { foo = 1, bar = 2 })).to.equal(true)
end)
it("should pass args to actions", function()
local passedArgs
local function action1(_, _, args)
passedArgs = args
end
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = { Event1 = { action = action1 } }
})
local theArgs = { argsAreHere = true }
st.events.Event1(theArgs)
expect(ShallowEqual(theArgs, passedArgs)).to.equal(true)
end)
it("should call actions independently for different events", function()
local testData1 = TEST_DATA
local testData2 = { foo = 2 }
local action1OldState, action1NewState, action1Data
local testAction1 = function(oldState, newState, data)
action1OldState = oldState
action1NewState = newState
action1Data = data
return data
end
local action2OldState, action2NewState, action2Data
local testAction2 = function(oldState, newState, data)
action2OldState = oldState
action2NewState = newState
action2Data = data
return data
end
local st = StateTable.new(TEST_NAME, TEST_INITIAL_STATE, {}, {
Initial = {
Event1 = { action = testAction1 },
Event2 = { nextState = "Two", action = testAction2 },
},
Two = {}
})
local oldState, newState, updatedContext
st:onStateChange(function(os, ns, uc)
oldState = os
newState = ns
updatedContext = uc
end)
st.events.Event1(testData1)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Initial")
expect(ShallowEqual(updatedContext, testData1)).to.equal(true)
st.events.Event2(testData2)
expect(oldState).to.equal("Initial")
expect(newState).to.equal("Two")
expect(ShallowEqual(updatedContext, testData2)).to.equal(true)
expect(action1OldState).to.equal("Initial")
expect(action2OldState).to.equal("Initial")
expect(action1NewState).to.equal("Initial")
expect(action2NewState).to.equal("Two")
expect(ShallowEqual(action1Data, testData1)).to.equal(true)
expect(ShallowEqual(action2Data, testData2)).to.equal(true)
end)
end)
end
@@ -0,0 +1,9 @@
-- Generator information:
-- Human name: State Table
-- Variable name: StateTable
-- Repo name: state-table
local StateTable = require(script.StateTable)
return StateTable