Tables within a ModuleScript randomly become nil after using table.remove()

I’m trying to make a fighting game system by using a ModuleScript that’s parented to each player’s character each time they spawn, which handles what abilities you can or can’t use at the time. It sets up a bunch of tables (attackDisableReasons, dashDisableReasons, etc.) that tracks the ongoing reasons why you CAN’T perform a specific action. Ideally, the ModuleScript allows you to both add and remove specific reasons from each table, and if a corresponding table is empty when a “getAbility” function is called, then you’re allowed to perform that ability as long as you’re otherwise allowed to act.

However, for some reason whenever a disable reason is added and then subsequently removed by another script, the entire reasoning table is seemingly destroyed as it unexpectedly returns nil the next time it is called, rather than returning an empty list. Because the table is destroyed, all of the get functions fail because they’re incorrectly looking to see if there are zero items inside of nil. I have no clue as to what’s causing the table to become nil, since nowhere in the ModuleScript is it coded to directly change what the tables themselves are, and other scripts don’t even have direct access to the tables.

-- ModuleScript "PlayerStateManager", cloned to Workspace > (Your Character) > ModuleScripts
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local storage = game.ReplicatedStorage
local player
if RunService:IsClient() then player = Players.LocalPlayer
elseif RunService:IsServer() then player = Players:GetPlayerFromCharacter(script.Parent.Parent)
	if not player then error("ModuleScript hierarchy was changed; couldn't find the corresponding player") end
end
local autoReplicate = storage.RemoteEvents.AutoReplication.PlayerStateChanged
local main = require(game.ReplicatedStorage.ModuleScripts.PlayerModule)

local PlayerStateManager = {}

local actDisableReasons = {} -- if any reason exists that the player can't act, then no other abilities may be used
local attackDisableReasons = {}  
local dashDisableReasons = {}
local grabDisableReasons = {}
local blockDisableReasons = {}

local anchoredReasons = {} -- currently unused
local jumpDisableReasons = {}

-- Player statistics
local playerWalkSpeed = 18
local playerJumpPower = 50
local defaultShieldHealth = 80
local shieldHealth = 80

function PlayerStateManager.getCanAct()
	if player.Character.Humanoid.Health <= 0
	or player.Character:GetAttribute("IsGrabbed")
	or player.Character:GetAttribute("IsGrabbing")
	or player.Character:GetAttribute("IsCarried")
	or player.Character:GetAttribute("IsCarrying")
	or player.Character:GetAttribute("IsRagdolled")
	or player.Character:GetAttribute("IsStunned") then
	return false end
	
	return (#actDisableReasons == 0), actDisableReasons
end

function PlayerStateManager.getCanAttack()
	if PlayerStateManager.getCanAct() then
		return (#attackDisableReasons == 0)
	else
		return false, attackDisableReasons, actDisableReasons
	end
end

function PlayerStateManager.getCanDash()
	if PlayerStateManager.getCanAct() then
		return (#dashDisableReasons == 0)
	else
		return false, dashDisableReasons, actDisableReasons
	end
end

function PlayerStateManager.getCanGrab()
	if PlayerStateManager.getCanAct() then
		return (#grabDisableReasons == 0)
	else
		return false, grabDisableReasons, actDisableReasons
	end
end

function PlayerStateManager.getCanBlock()
	if PlayerStateManager.getCanAct() then
		return (#blockDisableReasons == 0)
	else
		return false, blockDisableReasons, actDisableReasons
	end
end

function PlayerStateManager.getPlayerDefaultWalkSpeed()
	return playerWalkSpeed
end

function PlayerStateManager.getPlayerDefaultJumpPower()
	return playerJumpPower
end

function PlayerStateManager.getCanJump() -- JumpPower must be manually disabled per script; this is to ensure jumping is not reenabled improperly
	return (#jumpDisableReasons == 0), jumpDisableReasons
end

function PlayerStateManager.getShieldHealth()
	return shieldHealth
end

--[[
Adjusts the health of a player's bubble shield.  Their shield health will be incremented by the number provided.
"health" must be negative in order to damage a player's shield.

Using trueSet will forcefully set the shield health to the given health, rather than incrementing by it.
]]
function PlayerStateManager.setShieldHealth(health: number, trueSet: boolean?)
	if trueSet == true then
		shieldHealth = health
	else
		shieldHealth += health
	end
	if RunService:IsServer() then autoReplicate:FireClient(player, "ShieldHealth", shieldHealth) end
end

function PlayerStateManager.getDefaultShieldHealth()
	return defaultShieldHealth
end

function PlayerStateManager.setDefaultShieldHealth(health)
	defaultShieldHealth = health
	if RunService:IsServer() then autoReplicate:FireClient(player, "DefaultShieldHealth", health) end
end



local abilityMapping = {
	canact = {
		disableTable = actDisableReasons,
		replicate = function()
			if RunService:IsServer() then
				autoReplicate:FireClient(player, "CanAct", PlayerStateManager.getCanAct())
			end
		end,
		getter = PlayerStateManager.getCanAct
	},
	canattack = {
		disableTable = attackDisableReasons,
		replicate = function() 
			if RunService:IsServer() then 
				autoReplicate:FireClient(player, "CanAttack", PlayerStateManager.getCanAttack())
			end
		end,
		getter = PlayerStateManager.getCanAttack
	},
	candash = {
		disableTable = dashDisableReasons,
		replicate = function() 
			storage.RemoteEvents.PlayerActions.Abilities.CanDashChanged:Fire(PlayerStateManager.getCanDash()) -- this and similar RemoteEvents are for handling UI on the client
			if RunService:IsServer() then 
				autoReplicate:FireClient(player, "CanDash", PlayerStateManager.getCanDash())
			end
		end,
		getter = PlayerStateManager.getCanDash
	},
	cangrab = {
		disableTable = grabDisableReasons,
		replicate = function() 
			storage.RemoteEvents.PlayerActions.Grabs.CanGrabChanged:Fire(PlayerStateManager.getCanGrab())
			if RunService:IsServer() then 
				autoReplicate:FireClient(player, "CanGrab", PlayerStateManager.getCanGrab())
			end
		end,
		getter = PlayerStateManager.getCanGrab
	},
	canblock = {
		disableTable = blockDisableReasons,
		replicate = function() 
			if RunService:IsServer() then 
				autoReplicate:FireClient(player, "CanBlock", PlayerStateManager.getCanBlock())
			end
		end,
		getter = PlayerStateManager.getCanBlock
	},
	canjump = {
		disableTable = jumpDisableReasons,
		replicate = function()
			if RunService:IsServer() then
				autoReplicate:FireClient(player, "CanJump", PlayerStateManager.getCanJump())
			end
		end,
		getter = PlayerStateManager.getCanJump
	},
}

--[[
Adds a reason for a player's ability to be disabled.
Once a reason is added, it must be removed before that ability can be used again.
]]
function PlayerStateManager.setAbilityDisabled(ability: string, disableReason: string, state: boolean)
	local mapping = abilityMapping[string.lower(ability)]
	if not mapping then warn("Couldn't set", ability, "because", ability, "does not exist.") return end

	local disableTable = mapping.disableTable

	if state == true then
		if not table.find(disableTable, disableReason) then
			table.insert(disableTable, disableReason)
		end
	else
		for i, r in ipairs(disableTable) do
			if r == disableReason then
				table.remove(disableTable, i)
				break
			end
		end
	end

	mapping.replicate()
end




-- Whenever the server sets a reason why you can't perform an action, a dummy reason is set on the client until the server says the list is empty again
if RunService:IsClient() and script.Parent.Parent ~= game.StarterPlayer.StarterCharacterScripts then
	autoReplicate.OnClientEvent:Connect(function(state: string, value: boolean|number)
		if state == "ShieldHealth" then
			PlayerStateManager.setShieldHealth(value, true)
		elseif state == "DefaultShieldHealth" then
			PlayerStateManager.setDefaultShieldHealth(value)
		elseif type(value) == "boolean" then
			PlayerStateManager.setAbilityDisabled(state, "ClientReplicatedDisable", value)
		end
	end)
end

return PlayerStateManager

I discovered this issue while manipulating a disable reason for “actDisableReasons”, which is set like this within a grab handler script:

aggressorChrStates.setAbilityDisabled("CanAct", "IsGrabInitiator", true)
-- later...
if aggressorChrStates then aggressorChrStates.setAbilityDisabled("CanAct", "IsGrabInitiator", false) end

I set up the following debugger to run near the beginning and the end of a grab:

_, grabreasons, actreasons = aggressorChrStates.getCanGrab()
print("GrabDisableReasons:", grabreasons)
print("ActDisableReasons", actreasons)

At the beginning of the grab, it outputs this:
image

…which is what I want. But at the end of the grab, it outputs this:
image

The first set of reasons are fetched right before attempting to remove the IsGrabInitiator reason from the actDisableReasons table. They’re fine.
The second set of reasons, the ones that return nil, are fetched one second after IsGrabInitiator is removed from the actDisableReasons table.

My intuition tells me that the part of the PlayerStateManager.setAbilityDisabled() function that removes disable reasons from the tables is the issue, but I can’t figure out why it’s not only nuking the table, but also nuking the grabDisableReasons table as well, when that table was never even touched by the script that managed the IsGrabInitiator reason.

Same issue here, in my modulescript I have a dictionary, but half of the data is missing.
image
image
Cut is a function inside a different modulescript

Somehow now it does print out the whole table when I change the functions to numbers,
image
image