--!strict
local Core = script.Parent

if Core:GetAttribute("HotLoading") then
	task.wait(3)
end

for i, desc in script:GetDescendants() do
	if desc:IsA("BaseScript") then
		desc.Enabled = true
	end
end

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local StarterGui = game:GetService("StarterGui")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ContextActionService = game:GetService("ContextActionService")

local Shared = require(Core.Shared)
local Sounds = Shared.Sounds

local Enums = require(script.Enums)
local Mario = require(script.Mario)
local Types = require(script.Types)
local Util = require(script.Util)

local Action = Enums.Action
local Buttons = Enums.Buttons
local MarioFlags = Enums.MarioFlags
local ParticleFlags = Enums.ParticleFlags

type InputType = Enum.UserInputType | Enum.KeyCode
type Controller = Types.Controller
type Mario = Mario.Class

local player: Player = assert(Players.LocalPlayer)
local mario: Mario = Mario.new()

local STEP_RATE = 30
local NULL_TEXT = `<font color="#FF0000">NULL</font>`
local FLIP = CFrame.Angles(0, math.pi, 0)

local debugStats = Instance.new("BoolValue")
debugStats.Name = "DebugStats"
debugStats.Archivable = false
debugStats.Parent = game

local PARTICLE_CLASSES = {
	Fire = true,
	Smoke = true,
	Sparkles = true,
	ParticleEmitter = true,
}

local AUTO_STATS = {
	"Position",
	"Velocity",
	"AnimFrame",
	"FaceAngle",

	"ActionState",
	"ActionTimer",
	"ActionArg",

	"ForwardVel",
	"SlideVelX",
	"SlideVelZ",

	"CeilHeight",
	"FloorHeight",
	"WaterLevel",
}

local ControlModule: {
	GetMoveVector: (self: any) -> Vector3,
}

while not ControlModule do
	local inst = player:FindFirstChild("ControlModule", true)

	if inst then
		ControlModule = (require :: any)(inst)
	end

	task.wait(0.1)
end

-------------------------------------------------------------------------------------------------------------------------------------------------
-- Input Driver
-------------------------------------------------------------------------------------------------------------------------------------------------

-- NOTE: I had to replace the default BindAction via KeyCode and UserInputType
-- BindAction forces some mappings (such as R2 mapping to MouseButton1) which you
-- can't turn off otherwise.

local BUTTON_FEED = {}
local BUTTON_BINDS = {}

local function toStrictNumber(str: string): number
	local result = tonumber(str)
	return assert(result, "Invalid number!")
end

local function processAction(id: string, state: Enum.UserInputState, input: InputObject)
	if id == "MarioDebug" and Core:GetAttribute("DebugToggle") then
		if state == Enum.UserInputState.Begin then
			local character = player.Character

			if character then
				local isDebug = not character:GetAttribute("Debug")
				character:SetAttribute("Debug", isDebug)
			end
		end
	else
		local button = toStrictNumber(id:sub(5))
		BUTTON_FEED[button] = state
	end
end

local function processInput(input: InputObject, gameProcessedEvent: boolean)
	if gameProcessedEvent then
		return
	end
	if BUTTON_BINDS[input.UserInputType] ~= nil then
		processAction(BUTTON_BINDS[input.UserInputType], input.UserInputState, input)
	end
	if BUTTON_BINDS[input.KeyCode] ~= nil then
		processAction(BUTTON_BINDS[input.KeyCode], input.UserInputState, input)
	end
end

UserInputService.InputBegan:Connect(processInput)
UserInputService.InputChanged:Connect(processInput)
UserInputService.InputEnded:Connect(processInput)

local function bindInput(button: number, label: string, ...: InputType)
	local id = "BTN_" .. button

	if UserInputService.TouchEnabled then
		ContextActionService:BindAction(id, processAction, true)
		ContextActionService:SetTitle(id, label)
	end

	for i, input in { ... } do
		BUTTON_BINDS[input] = id
	end
end

local function updateCollisions()
	for i, player in Players:GetPlayers() do
		-- stylua: ignore
		local character = player.Character
		local rootPart = character and character.PrimaryPart

		if rootPart then
			local parts = rootPart:GetConnectedParts(true)

			for i, part in parts do
				if part:IsA("BasePart") then
					part.CanCollide = false
				end
			end
		end
	end
end

local function updateController(controller: Controller, humanoid: Humanoid?)
	if not humanoid then
		return
	end

	local moveDir = ControlModule:GetMoveVector()
	local pos = Vector2.new(moveDir.X, -moveDir.Z)
	local mag = 0

	if pos.Magnitude > 0 then
		if pos.Magnitude > 1 then
			pos = pos.Unit
		end

		mag = pos.Magnitude
	end

	controller.StickMag = mag * 64
	controller.StickX = pos.X * 64
	controller.StickY = pos.Y * 64

	humanoid:ChangeState(Enum.HumanoidStateType.Physics)
	controller.ButtonPressed:Clear()

	if humanoid.Jump then
		BUTTON_FEED[Buttons.A_BUTTON] = Enum.UserInputState.Begin
	elseif controller.ButtonDown:Has(Buttons.A_BUTTON) then
		BUTTON_FEED[Buttons.A_BUTTON] = Enum.UserInputState.End
	end

	local lastButtonValue = controller.ButtonDown()

	for button, state in pairs(BUTTON_FEED) do
		if state == Enum.UserInputState.Begin then
			controller.ButtonDown:Add(button)
		elseif state == Enum.UserInputState.End then
			controller.ButtonDown:Remove(button)
		end
	end

	table.clear(BUTTON_FEED)

	local buttonValue = controller.ButtonDown()
	controller.ButtonPressed:Set(buttonValue)
	controller.ButtonPressed:Band(bit32.bxor(buttonValue, lastButtonValue))

	local character = humanoid.Parent
	if (character and character:GetAttribute("TAS")) or Core:GetAttribute("ToolAssistedInput") then
		if not mario.Action:Has(Enums.ActionFlags.SWIMMING) then
			if controller.ButtonDown:Has(Buttons.A_BUTTON) then
				controller.ButtonPressed:Set(Buttons.A_BUTTON)
			end
		end
	end
end

ContextActionService:BindAction("MarioDebug", processAction, false, Enum.KeyCode.P)
bindInput(Buttons.B_BUTTON, "B", Enum.UserInputType.MouseButton1, Enum.KeyCode.ButtonX)
bindInput(
	Buttons.Z_TRIG,
	"Z",
	Enum.KeyCode.LeftShift,
	Enum.KeyCode.RightShift,
	Enum.KeyCode.ButtonL2,
	Enum.KeyCode.ButtonR2
)

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Network Dispatch
-------------------------------------------------------------------------------------------------------------------------------------------------------------

local Commands = {}
local soundDecay = {}

local lazyNetwork = ReplicatedStorage:WaitForChild("LazyNetwork")
assert(lazyNetwork:IsA("RemoteEvent"), "bad lazyNetwork!")

local function stepDecay(sound: Sound)
	local decay = soundDecay[sound]

	if decay then
		task.cancel(decay)
	end

	soundDecay[sound] = task.delay(0.1, function()
		sound:Stop()
		sound:Destroy()
		soundDecay[sound] = nil
	end)

	sound.Playing = true
end

function Commands.PlaySound(player: Player, name: string)
	local sound: Sound? = Sounds[name]
	local character = player.Character
	local rootPart = character and character.PrimaryPart

	if rootPart and sound then
		local oldSound: Instance? = rootPart:FindFirstChild(name)
		local canPlay = true

		if oldSound and oldSound:IsA("Sound") then
			canPlay = false

			if name:sub(1, 6) == "MOVING" or sound:GetAttribute("Decay") then
				-- Keep decaying audio alive.
				stepDecay(oldSound)
			elseif name:sub(1, 5) == "MARIO" then
				-- Restart mario sound if a 30hz interval passed.
				local now = os.clock()
				local lastPlay = oldSound:GetAttribute("LastPlay") or 0

				if now - lastPlay >= 2 / STEP_RATE then
					oldSound.TimePosition = 0
					oldSound:SetAttribute("LastPlay", now)
				end
			else
				-- Allow stacking.
				canPlay = true
			end
		end

		if canPlay then
			local newSound: Sound = sound:Clone()
			newSound.Parent = rootPart
			newSound:Play()

			if name:find("MOVING") then
				-- Audio will decay if PlaySound isn't continuously called.
				stepDecay(newSound)
			end

			newSound.Ended:Connect(function()
				newSound:Destroy()
			end)

			newSound:SetAttribute("LastPlay", os.clock())
		end
	end
end

function Commands.SetParticle(player: Player, name: string, set: boolean)
	local character = player.Character
	local rootPart = character and character.PrimaryPart

	if rootPart then
		local particles = rootPart:FindFirstChild("Particles")
		local inst = particles and particles:FindFirstChild(name, true)

		if inst and PARTICLE_CLASSES[inst.ClassName] then
			local particle = inst :: ParticleEmitter
			local emit = particle:GetAttribute("Emit")

			if typeof(emit) == "number" then
				particle:Emit(emit)
			elseif set ~= nil then
				particle.Enabled = set
			end
		else
			warn("particle not found:", name)
		end
	end
end

function Commands.SetTorsoAngle(player: Player, angle: Vector3int16)
	local character = player.Character
	local waist = character and character:FindFirstChild("Waist", true)

	if waist and waist:IsA("Motor6D") then
		local props = { C1 = Util.ToRotation(-angle) + waist.C1.Position }
		local tween = TweenService:Create(waist, TweenInfo.new(0.1), props)
		tween:Play()
	end
end

function Commands.SetHeadAngle(player: Player, angle: Vector3int16)
	local character = player.Character
	local neck = character and character:FindFirstChild("Neck", true)

	if neck and neck:IsA("Motor6D") then
		local props = { C1 = Util.ToRotation(-angle) + neck.C1.Position }
		local tween = TweenService:Create(neck, TweenInfo.new(0.1), props)
		tween:Play()
	end
end

function Commands.SetCamera(player: Player, cf: CFrame?)
	local camera = workspace.CurrentCamera

	if cf ~= nil then
		camera.CameraType = Enum.CameraType.Scriptable
		camera.CFrame = cf
	else
		camera.CameraType = Enum.CameraType.Custom
	end
end

local function processCommand(player: Player, cmd: string, ...: any)
	local command = Commands[cmd]

	if command then
		task.spawn(command, player, ...)
	else
		warn("Unknown Command:", cmd, ...)
	end
end

local function networkDispatch(cmd: string, ...: any)
	lazyNetwork:FireServer(cmd, ...)
	processCommand(player, cmd, ...)
end

local function onNetworkReceive(target: Player, cmd: string, ...: any)
	if target ~= player then
		processCommand(target, cmd, ...)
	end
end

lazyNetwork.OnClientEvent:Connect(onNetworkReceive)

-------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Mario Driver
-------------------------------------------------------------------------------------------------------------------------------------------------------------

local lastUpdate = os.clock()
local lastHeadAngle: Vector3int16?
local lastTorsoAngle: Vector3int16?

local activeScale = 1
local subframe = 0 -- 30hz subframe
local emptyId = ""

local goalCF: CFrame
local prevCF: CFrame
local activeTrack: AnimationTrack?

local reset = Instance.new("BindableEvent")
reset.Archivable = false
reset.Parent = script
reset.Name = "Reset"

if RunService:IsStudio() then
	local dummySequence = Instance.new("KeyframeSequence")
	local provider = game:GetService("KeyframeSequenceProvider")
	emptyId = provider:RegisterKeyframeSequence(dummySequence)
end

while not player.Character do
	player.CharacterAdded:Wait()
end

local character = assert(player.Character)
local pivot = character:GetPivot()
mario.Position = Util.ToSM64(pivot.Position)

goalCF = pivot
prevCF = pivot

local function setDebugStat(key: string, value: any)
	if typeof(value) == "Vector3" then
		value = string.format("%.3f, %.3f, %.3f", value.X, value.Y, value.Z)
	elseif typeof(value) == "Vector3int16" then
		value = string.format("%i, %i, %i", value.X, value.Y, value.Z)
	elseif type(value) == "number" then
		value = string.format("%.3f", value)
	end

	debugStats:SetAttribute(key, value)
end

local function onReset()
	local roblox = Vector3.yAxis * 100
	local sm64 = Util.ToSM64(roblox)
	local char = player.Character

	if char then
		local reset = char:FindFirstChild("Reset")

		local cf = CFrame.new(roblox)
		char:PivotTo(cf)

		goalCF = cf
		prevCF = cf

		if reset and reset:IsA("RemoteEvent") then
			reset:FireServer()
		end
	end

	mario.SlideVelX = 0
	mario.SlideVelZ = 0
	mario.ForwardVel = 0
	mario.IntendedYaw = 0

	mario.Position = sm64
	mario.Velocity = Vector3.zero
	mario.FaceAngle = Vector3int16.new()

	mario:SetAction(Action.SPAWN_SPIN_AIRBORNE)
end

local function getWaterLevel(pos: Vector3)
	local terrain = workspace.Terrain
	local voxelPos = terrain:WorldToCellPreferSolid(pos)

	local voxelRegion = Region3.new(voxelPos * 4, (voxelPos + Vector3.one + (Vector3.yAxis * 3)) * 4)
	voxelRegion = voxelRegion:ExpandToGrid(4)

	local materials, occupancies = terrain:ReadVoxels(voxelRegion, 4)
	local size: Vector3 = occupancies.Size
	local waterLevel = -11000

	for y = 1, size.Y do
		local occupancy = occupancies[1][y][1]
		local material = materials[1][y][1]

		if occupancy >= 0.9 and material == Enum.Material.Water then
			local top = ((voxelPos.Y * 4) + (4 * y + 2))
			waterLevel = math.max(waterLevel, top / Util.Scale)
		end
	end

	return waterLevel
end

local function update()
	local character = player.Character

	if not character then
		return
	end

	local now = os.clock()
	local gfxRot = CFrame.identity
	local scale = character:GetScale()

	if scale ~= activeScale then
		local marioPos = Util.ToRoblox(mario.Position)
		Util.Scale = scale / 20 -- HACK! Should this be instanced?

		mario.Position = Util.ToSM64(marioPos)
		activeScale = scale
	end

	-- Disabled for now because this causes parallel universes to break.
	-- TODO: Find a better way to do two-way syncing between these values.

	-- local pos = character:GetPivot().Position
	-- local dist = (Util.ToRoblox(mario.Position) - pos).Magnitude

	-- if dist > (scale * 20)  then
	-- 	mario.Position = Util.ToSM64(pos)
	-- end

	local humanoid = character:FindFirstChildOfClass("Humanoid")
	local simSpeed = tonumber(character:GetAttribute("TimeScale") or nil) or 1

	local robloxPos = Util.ToRoblox(mario.Position)
	mario.WaterLevel = getWaterLevel(robloxPos)
	Util.DebugWater(mario.WaterLevel)

	subframe += (now - lastUpdate) * (STEP_RATE * simSpeed)
	lastUpdate = now

	if character:GetAttribute("WingCap") or Core:GetAttribute("WingCap") then
		mario.Flags:Add(MarioFlags.WING_CAP)
	else
		mario.Flags:Remove(MarioFlags.WING_CAP)
	end

	if character:GetAttribute("Metal") then
		mario.Flags:Add(MarioFlags.METAL_CAP)
	else
		mario.Flags:Remove(MarioFlags.METAL_CAP)
	end

	subframe = math.min(subframe, 4) -- Prevent execution runoff

	while subframe >= 1 do
		subframe -= 1
		updateCollisions()

		updateController(mario.Controller, humanoid)
		mario:ExecuteAction()

		local gfxPos = Util.ToRoblox(mario.Position)
		gfxRot = Util.ToRotation(mario.GfxAngle)

		prevCF = goalCF
		goalCF = CFrame.new(gfxPos) * FLIP * gfxRot
	end

	if character and goalCF then
		local cf = character:GetPivot()
		local rootPart = character.PrimaryPart
		local animator = character:FindFirstChildWhichIsA("Animator", true)

		if animator and (mario.AnimDirty or mario.AnimReset) and mario.AnimFrame >= 0 then
			local anim = mario.AnimCurrent
			local animSpeed = 0.1 / simSpeed

			if activeTrack and (activeTrack.Animation ~= anim or mario.AnimReset) then
				if tostring(activeTrack.Animation) == "TURNING_PART1" then
					if anim and anim.Name == "TURNING_PART2" then
						mario.AnimSkipInterp = 2
						animSpeed *= 2
					end
				end

				activeTrack:Stop(animSpeed)
				activeTrack = nil
			end

			if not activeTrack and anim then
				if anim.AnimationId == "" then
					if RunService:IsStudio() then
						warn("!! FIXME: Empty AnimationId for", anim.Name, "will break in live games!")
					end

					anim.AnimationId = emptyId
				end

				local track = animator:LoadAnimation(anim)
				track:Play(animSpeed, 1, 0)
				activeTrack = track
			end

			if activeTrack then
				local speed = mario.AnimAccel / 0x10000

				if speed > 0 then
					activeTrack:AdjustSpeed(speed * simSpeed)
				else
					activeTrack:AdjustSpeed(simSpeed)
				end
			end

			mario.AnimDirty = false
			mario.AnimReset = false
		end

		if activeTrack and mario.AnimSetFrame > -1 then
			activeTrack.TimePosition = mario.AnimSetFrame / STEP_RATE
			mario.AnimSetFrame = -1
		end

		if rootPart then
			local particles = rootPart:FindFirstChild("Particles")
			local alignPos = rootPart:FindFirstChildOfClass("AlignPosition")
			local alignCF = rootPart:FindFirstChildOfClass("AlignOrientation")

			local actionId = mario.Action()
			local throw = mario.ThrowMatrix

			if throw then
				local throwPos = Util.ToRoblox(throw.Position)
				goalCF = throw.Rotation * FLIP + throwPos
			end

			if alignCF then
				local nextCF = prevCF:Lerp(goalCF, subframe)

				-- stylua: ignore
				cf = if mario.AnimSkipInterp > 0
					then cf.Rotation + nextCF.Position
					else nextCF

				alignCF.CFrame = cf.Rotation
			end

			local isDebug = character:GetAttribute("Debug")
			local limits = character:GetAttribute("EmulateLimits")

			script.Util:SetAttribute("Debug", isDebug)
			debugStats.Value = isDebug

			if limits ~= nil then
				Core:SetAttribute("TruncateBounds", limits)
			end

			if isDebug then
				local animName = activeTrack and tostring(activeTrack.Animation)
				setDebugStat("Animation", animName)

				local actionName = Enums.GetName(Action, actionId)
				setDebugStat("Action", actionName)

				local wall = mario.Wall
				setDebugStat("Wall", wall and wall.Instance.Name or NULL_TEXT)

				local floor = mario.Floor
				setDebugStat("Floor", floor and floor.Instance.Name or NULL_TEXT)

				local ceil = mario.Ceil
				setDebugStat("Ceiling", ceil and ceil.Instance.Name or NULL_TEXT)
			end

			for _, name in AUTO_STATS do
				local value = rawget(mario :: any, name)
				setDebugStat(name, value)
			end

			if alignPos then
				alignPos.Position = cf.Position
			end

			local bodyState = mario.BodyState
			local headAngle = bodyState.HeadAngle
			local torsoAngle = bodyState.TorsoAngle

			if actionId ~= Action.BUTT_SLIDE and actionId ~= Action.WALKING then
				bodyState.TorsoAngle *= 0
			end

			if torsoAngle ~= lastTorsoAngle then
				networkDispatch("SetTorsoAngle", torsoAngle)
				lastTorsoAngle = torsoAngle
			end

			if headAngle ~= lastHeadAngle then
				networkDispatch("SetHeadAngle", headAngle)
				lastHeadAngle = headAngle
			end

			if particles then
				for name, flag in pairs(ParticleFlags) do
					local inst = particles:FindFirstChild(name, true)

					if inst and PARTICLE_CLASSES[inst.ClassName] then
						local particle = inst :: ParticleEmitter
						local emit = particle:GetAttribute("Emit")
						local hasFlag = mario.ParticleFlags:Has(flag)

						if hasFlag then
							print("SetParticle", name)
						end

						if emit then
							if hasFlag then
								networkDispatch("SetParticle", name)
							end
						elseif particle.Enabled ~= hasFlag then
							networkDispatch("SetParticle", name, hasFlag)
						end
					end
				end
			end

			for name: string, sound: Sound in pairs(Sounds) do
				local looped = false

				if sound:IsA("Sound") then
					if sound.TimeLength == 0 then
						continue
					end

					looped = sound.Looped
				end

				if sound:GetAttribute("Play") then
					networkDispatch("PlaySound", sound.Name)

					if not looped then
						sound:SetAttribute("Play", false)
					end
				elseif looped then
					sound:Stop()
				end
			end

			character:PivotTo(cf)
		end
	end
end

reset.Event:Connect(onReset)
RunService.Heartbeat:Connect(update)

while task.wait(1) do
	local success = pcall(function()
		return StarterGui:SetCore("ResetButtonCallback", reset)
	end)

	if success then
		break
	end
end

-------------------------------------------------------------------------------------------------------------------------------------------------------------


--!strict
local Validators: { [string]: (Player, ...any) -> boolean } = {}
type Echo = () -> ()

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Core = script.Parent.Parent

local Shared = require(Core.Shared)
local Sounds = Shared.Sounds

local lazy = Instance.new("RemoteEvent")
lazy.Parent = ReplicatedStorage
lazy.Name = "LazyNetwork"
lazy.Archivable = false

function Validators.PlaySound(player: Player, name: string)
	local sound: Instance? = Sounds[name]

	if sound and sound:IsA("Sound") then
		return true
	end

	return false
end

function Validators.SetParticle(player: Player, name: string, set: boolean?)
	if typeof(name) ~= "string" then
		return false
	end

	local character = player.Character
	local rootPart = character and character.PrimaryPart

	if rootPart then
		local particles = rootPart:FindFirstChild("Particles")
		local particle = particles and particles:FindFirstChild(name, true)

		if particle then
			return true
		end
	end

	return false
end

function Validators.SetTorsoAngle(player: Player, angle: Vector3int16)
	return typeof(angle) == "Vector3int16"
end

function Validators.SetHeadAngle(player: Player, angle: Vector3int16)
	return typeof(angle) == "Vector3int16"
end

local function onNetworkReceive(player: Player, cmd: string, ...)
	local validate = Validators[cmd]

	if validate and validate(player, ...) then
		lazy:FireAllClients(player, cmd, ...)
	end
end

lazy.OnServerEvent:Connect(onNetworkReceive)


--!strict
local Players = game:GetService("Players")

local character: Instance = assert(script.Parent)
assert(character:IsA("Model"), "Not a character")

local player = Players:GetPlayerFromCharacter(character)
assert(player, "No player!")

local userId = player.UserId
local hDesc: HumanoidDescription?

local metalPointers = {} :: {
	[MeshPart]: {
		Metal: SurfaceAppearance?,
	},
}

local function updateMetal(part: MeshPart)
	local isMetal = character:GetAttribute("Metal")
	local ptr = metalPointers[part]

	if ptr == nil then
		ptr = {}
		metalPointers[part] = ptr
	end

	if isMetal and not ptr.Metal then
		local surface = script.METAL_MARIO:Clone()
		surface.Parent = part
		ptr.Metal = surface
	elseif ptr.Metal and not isMetal then
		ptr.Metal:Destroy()
		ptr.Metal = nil
	end
end

local function onMetalChanged()
	for meshPart in metalPointers do
		updateMetal(meshPart)
	end
end

local function onDescendantAdded(desc: Instance)
	if desc:IsA("BasePart") then
		if desc.CollisionGroup ~= "Player" then
			local canCollide = desc:GetPropertyChangedSignal("CanCollide")
			desc.CollisionGroup = "Player"
			desc.CanQuery = false
			desc.CanTouch = false
			desc.Massless = true

			canCollide:Connect(function()
				desc.CanCollide = false
			end)

			desc.CanCollide = false
		end

		if desc:IsA("MeshPart") then
			updateMetal(desc)
		end
	end
end

local function onDescendantRemoving(desc: Instance)
	if desc:IsA("MeshPart") then
		metalPointers[desc] = nil
	end
end

local metalListener = character:GetAttributeChangedSignal("Metal")
metalListener:Connect(onMetalChanged)

for i, desc in character:GetDescendants() do
	task.spawn(onDescendantAdded, desc)
end

character:SetAttribute("TimeScale", 1)
character.DescendantAdded:Connect(onDescendantAdded)
character.DescendantRemoving:Connect(onDescendantRemoving)

local function reload()
	character:ScaleTo(1)

	task.spawn(function()
		for i = 1, 5 do
			character:PivotTo(CFrame.new(0, 100, 0))
			task.wait()
		end
	end)

	for retry = 1, 10 do
		local success, result = pcall(function()
			return Players:GetHumanoidDescriptionFromUserId(userId)
		end)

		if success then
			hDesc = result
			break
		else
			task.wait(retry / 2)
		end
	end

	if hDesc then
		hDesc.HeadScale = 1.8
		hDesc.WidthScale = 1.3
		hDesc.DepthScale = 1.4
		hDesc.HeightScale = 1.2
		hDesc.BodyTypeScale = 0
		hDesc.ProportionScale = 0
	else
		return
	end

	local humanoid = character:WaitForChild("Humanoid")
	assert(hDesc)

	if humanoid:IsA("Humanoid") then
		while not humanoid.RootPart do
			humanoid.Changed:Wait()
		end

		local rootPart = humanoid.RootPart
		assert(rootPart, "No HumanoidRootPart??")

		local particles = rootPart:FindFirstChild("Particles")
		humanoid:ApplyDescription(hDesc)

		if particles and particles:IsA("Attachment") then
			local floorDec = humanoid.HipHeight + (rootPart.Size.Y / 2)
			local pos = Vector3.new(0, -floorDec, 0)
			rootPart.PivotOffset = CFrame.new(pos)
			particles.Position = pos
		end
	end
end

local reset = Instance.new("RemoteEvent")
reset.Parent = character
reset.Name = "Reset"

reset.OnServerEvent:Connect(function(player)
	if player == Players:GetPlayerFromCharacter(character) then
		reload()
	end
end)

task.spawn(reload)


--!strict

local Players = game:GetService("Players")
local StarterPlayer = game:GetService("StarterPlayer")
local PhysicsService = game:GetService("PhysicsService")
local StarterCharacterScripts = StarterPlayer.StarterCharacterScripts

local hDesc = Instance.new("HumanoidDescription")
hDesc.HeightScale = 1.3
hDesc.WidthScale = 1.3
hDesc.DepthScale = 1.4
hDesc.HeadScale = 2

local character = Players:CreateHumanoidModelFromDescription(hDesc, Enum.HumanoidRigType.R15)
local bodyColors = character:FindFirstChildOfClass("BodyColors")
local animate = character:FindFirstChild("Animate")
local oldRoot = character.PrimaryPart

if animate then
	animate:Destroy()
end

if oldRoot then
	oldRoot:Destroy()
end

if bodyColors then
	bodyColors:Destroy()
end

local newRoot = script.HumanoidRootPart
newRoot.Parent = character :: any

local humanoid = assert(character:FindFirstChildOfClass("Humanoid"))
humanoid:BuildRigFromAttachments()

local dummyScripts = {
	"Animate",
	"Health",
	"Sound",
}

for _, dummy in dummyScripts do
	local stub = Instance.new("Hole", StarterCharacterScripts)
	stub.Name = dummy
end

for _, child in script:GetChildren() do
	child.Parent = StarterCharacterScripts

	if child:IsA("Script") then
		child.Disabled = false
	end
end

character.Name = "StarterCharacter"
character.PrimaryPart = newRoot
character.Parent = StarterPlayer

PhysicsService:RegisterCollisionGroup("Player")
PhysicsService:CollisionGroupSetCollidable("Default", "Player", false)

for _, player in Players:GetPlayers() do
	task.spawn(player.LoadCharacter, player)
end


local AvatarImportService = game:GetService("AvatarImportService")
local resume = Instance.new("BindableEvent")

for i, anim in pairs(game.ReplicatedFirst.SM64.Assets.Animations:GetChildren()) do
	local path = "C:/Users/clone/Desktop/MarioAnims/" .. anim.Name .. ".fbx"
	print("Importing", anim)

	task.defer(function()
		local success, err = pcall(function()
			AvatarImportService:ImportFBXAnimationFromFilePathUserMayChooseModel(path, workspace.Mario, function()
				local bin = game.ServerStorage.AnimSaves
				local old = bin:FindFirstChild(anim.Name)

				if old then
					old:Destroy()
				end

				local kfs = AvatarImportService:ImportLoadedFBXAnimation(false)
				kfs.Name = anim.Name
				kfs.Parent = bin

				resume:Fire()
			end)
		end)

		if not success then
			warn("ERROR IMPORTING", anim, err)
			resume:Fire()
		end
	end)

	resume.Event:Wait()
end


-- This script was written (quite rigidly) for use in the provided RetargetAnimations.rbxl place.
-- KeyframeSequences are not provided as mentioned in the README, you'll have to extract them yourself :)

--!strict
local ServerStorage = game:GetService("ServerStorage")
local StarterCharacter = workspace.StarterCharacter

local MarioAnim = workspace.MarioAnim
local MarioBase = workspace.MarioBase
local Player = workspace.Player

local HIERARCHY: { [string]: { [string]: string }? } = {
	HumanoidRootPart = { LowerTorso = "Root" },

	LowerTorso = {
		UpperTorso = "Waist",
		LeftUpperLeg = "LeftHip",
		RightUpperLeg = "RightHip",
	},

	UpperTorso = {
		Head = "Neck",
		LeftUpperArm = "LeftShoulder",
		RightUpperArm = "RightShoulder",
	},

	LeftUpperArm = { LeftLowerArm = "LeftElbow" },
	LeftLowerArm = { LeftHand = "LeftWrist" },

	RightUpperArm = { RightLowerArm = "RightElbow" },
	RightLowerArm = { RightHand = "RightWrist" },

	LeftUpperLeg = { LeftLowerLeg = "LeftKnee" },
	LeftLowerLeg = { LeftFoot = "LeftAnkle" },

	RightUpperLeg = { RightLowerLeg = "RightKnee" },
	RightLowerLeg = { RightFoot = "RightAnkle" },
}

local BASE_KEYFRAME = ServerStorage.BASE_KEYFRAME

local statusHint = Instance.new("Hint")
local statusText = "%s [%d/%d]"
statusHint.Parent = workspace
statusHint.Name = "Status"

local function updateAnim()
	StarterCharacter.Humanoid.Animator:StepAnimations(0)
	Player.Humanoid.Animator:StepAnimations(0)
	task.wait()
end

local function clearAnims()
	for i, desc: Instance in pairs(workspace:GetDescendants()) do
		if desc:IsA("Bone") then
			desc.Transform = CFrame.identity
		elseif desc:IsA("Motor6D") then
			desc.Transform = CFrame.identity
		elseif desc:IsA("Animator") then
			task.defer(desc.StepAnimations, desc, 0)
		end
	end

	task.wait()
end

local function applyMotors(at: Instance)
	local name0 = if at:IsA("Keyframe") then "HumanoidRootPart" else at.Name
	local part0 = StarterCharacter:FindFirstChild(name0)
	local data = HIERARCHY[name0]

	if data and part0 and part0:IsA("BasePart") then
		for name1, motorName in data do
			local part1 = StarterCharacter:FindFirstChild(name1)

			if part1 and part1:IsA("BasePart") then
				local att: Attachment = part1:FindFirstChild(motorName .. "RigAttachment")
				local bone: Bone = MarioBase:FindFirstChild(motorName, true)

				local motor: Motor6D = part1:FindFirstChild(motorName)
				motor.Transform = att.WorldCFrame:ToObjectSpace(bone.TransformedWorldCFrame)

				local playerMotor = workspace.Player:FindFirstChild(motorName, true)
				local pose = at:FindFirstChild(name1)

				if playerMotor and playerMotor:IsA("Motor6D") then
					if motorName:find("Left") or motorName:find("Right") then
						playerMotor.Transform = motor.Transform.Rotation
					else
						playerMotor.Transform = motor.Transform
					end
				end

				updateAnim()

				if pose and pose:IsA("Pose") then
					if motorName:find("Left") or motorName:find("Right") then
						pose.CFrame = motor.Transform.Rotation
					else
						pose.CFrame = motor.Transform
					end

					applyMotors(pose)
				end
			end
		end
	end
end

local function remapKeyframe(keyframe: Keyframe): Keyframe
	clearAnims()

	for i, desc: Instance in pairs(keyframe:GetDescendants()) do
		if desc:IsA("Pose") then
			local bone: Instance? = MarioAnim:FindFirstChild(desc.Name, true)

			if bone and bone:IsA("Bone") then
				bone.Transform = desc.CFrame
			end
		end
	end

	for i, desc in MarioBase:GetDescendants() do
		if desc:IsA("Bone") then
			local anim = MarioAnim:FindFirstChild(desc.Name, true)

			if anim then
				local offset = desc.TransformedWorldCFrame:ToObjectSpace(anim.TransformedWorldCFrame)
				desc.Transform = offset
			end
		end
	end

	local newKeyframe = BASE_KEYFRAME:Clone()
	newKeyframe.Name = keyframe.Name
	newKeyframe.Time = keyframe.Time
	applyMotors(newKeyframe)

	return newKeyframe
end

local function remapKeyframeSequence(kfs: KeyframeSequence): KeyframeSequence
	local keyframes = kfs:GetKeyframes()
	clearAnims()

	local newKfs = kfs:Clone()
	newKfs:ClearAllChildren()

	for i, keyframe in keyframes do
		if keyframe:IsA("Keyframe") then
			local text = statusText:format(kfs.Name, i, #keyframes)
			statusHint.Text = text

			local newKeyframe = remapKeyframe(keyframe)
			newKeyframe.Parent = newKfs
		end
	end

	return newKfs
end

local animSaves = ServerStorage.AnimSaves:GetChildren()
local animSavesR15 = ServerStorage.AnimSaves_R15

table.sort(animSaves, function(a, b)
	return a.Name < b.Name
end)

for i, animSave in animSaves do
	if animSave:IsA("KeyframeSequence") then
		local kfs = remapKeyframeSequence(animSave)
		kfs.Parent = animSavesR15
	end
end

clearAnims()
statusHint:Destroy()


local fs = require("@std/fs")
local process = require("@lute/process")

fs.createdirectory("build")

process.run({ "rojo", "build", "--output=build/ripple.rbxm" }, { stdio = "inherit" })


local fs = require("@std/fs")
local net = require("@lute/net")
local process = require("@lute/process")

process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })

fs.writestringtofile(
	"roblox.d.luau",
	net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
)

local analyze = process.run({
	"luau-lsp",
	"analyze",
	"--sourcemap=sourcemap.json",
	"--ignore=**/node_modules/**",
	"--base-luaurc=.luaurc",
	"--definitions=roblox.d.luau",
	"packages",
	"tests",
	"benchmarks",
}, { stdio = "inherit" })

fs.remove("roblox.d.luau")

local selene = process.run({ "selene", "packages", "tests", "benchmarks", "storybook" }, { stdio = "inherit" })

local stylua = process.run(
	{ "stylua", "--check", "packages", "tests", "benchmarks", "storybook" },
	{ stdio = "inherit" }
)

local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" })

if not (analyze.ok and selene.ok and stylua.ok and eslint.ok) then
	process.exit(1)
end


local function bench(name: string, test: () -> ())
	local start = os.clock()
	local iterations = 0

	while os.clock() - start < 3 do
		iterations += 1
		test()
	end

	local elapsed = os.clock() - start
	local micros = string.format("%.2f", elapsed / iterations * 1000 * 1000)

	print(`{name}: {micros} µs/iter`)
end

return bench


--[[
	Copyright (c) 2018-2023 Roblox Corporation

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict
local boundCallbacks = {}

return {
	Connect = function(_, callback: (number) -> ())
		boundCallbacks[callback] = true
		return {
			Disconnect = function()
				boundCallbacks[callback] = nil
			end,
		} :: any
	end,
	Fire = function(_, dt: number)
		for callback, _ in boundCallbacks do
			callback(dt)
		end
	end,
	Clear = function(_)
		table.clear(boundCallbacks)
	end,
}


--[[
	MIT License

	Copyright (c) Roblox

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict

-- Marker used to specify that the value is nothing, because nil cannot be
-- stored in tables.
local None = newproxy(true)
local mt = getmetatable(None)
mt.__tostring = function()
	return "Object.None"
end

--[[
	Merges values from zero or more tables onto a target table. If a value is
	set to None, it will instead be removed from the table.

	This function is identical in functionality to JavaScript's Object.assign.
]]
-- Luau TODO: no way to strongly type this accurately, it doesn't eliminate deleted keys of T, and Luau won't do intersections of type packs: <T, ...U>(T, ...: ...U): T & ...U
return function<T, U, V, W>(target: T, source0: U?, source1: V?, source2: W?, ...): T & U & V & W
	if source0 ~= nil and typeof(source0 :: any) == "table" then
		for key, value in pairs(source0 :: any) do
			if value == None then
				(target :: any)[key] = nil
			else
				(target :: any)[key] = value
			end
		end
	end

	if source1 ~= nil and typeof(source1 :: any) == "table" then
		for key, value in pairs(source1 :: any) do
			if value == None then
				(target :: any)[key] = nil
			else
				(target :: any)[key] = value
			end
		end
	end

	if source2 ~= nil and typeof(source2 :: any) == "table" then
		for key, value in pairs(source2 :: any) do
			if value == None then
				(target :: any)[key] = nil
			else
				(target :: any)[key] = value
			end
		end
	end

	for index = 1, select("#", ...) do
		local rest = select(index, ...)

		if rest ~= nil and typeof(rest) == "table" then
			for key, value in pairs(rest) do
				if value == None then
					(target :: any)[key] = nil
				else
					(target :: any)[key] = value
				end
			end
		end
	end

	-- TODO? we can add & Object to this, if needed by real-world code, once CLI-49825 is fixed
	return (target :: any) :: T & U & V & W
end


--[[
	Copyright (c) 2023 Roblox Corporation

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict

local assign = require("./assign")
local createSignal = require("./createSignal")

local AnimationStepSignal = require("./AnimationStepSignal")

local types = require("./types")
type AnimationValue = types.AnimationValue
type Goal<T> = types.Goal<T>
type State = types.State
type Motor<T, U> = types.Motor<T, U>

type Unsubscribe = types.Unsubscribe
type MotorCallback<T> = types.MotorCallback<T>

type ValueGroup = {
	[string]: AnimationValue,
}
type GoalGroup = {
	[string]: Goal<any>,
}

type GroupMotorInternal = {
	__goals: GoalGroup,
	__states: {
		[string]: State,
	},
	__allComplete: boolean,
	__onComplete: createSignal.Signal<ValueGroup>,
	__fireOnComplete: createSignal.FireSignal<ValueGroup>,
	__onStep: createSignal.Signal<ValueGroup>,
	__fireOnStep: createSignal.FireSignal<ValueGroup>,
	__running: boolean,
	__connection: RBXScriptConnection?,

	start: (self: GroupMotorInternal) -> (),
	stop: (self: GroupMotorInternal) -> (),
	step: (self: GroupMotorInternal, dt: number) -> (),
	setGoal: (self: GroupMotorInternal, goal: GoalGroup) -> (),
	onStep: (self: GroupMotorInternal, callback: MotorCallback<ValueGroup>) -> Unsubscribe,
	onComplete: (self: GroupMotorInternal, callback: MotorCallback<ValueGroup>) -> Unsubscribe,
	destroy: (self: GroupMotorInternal) -> (),
}

local GroupMotor = {} :: GroupMotorInternal
(GroupMotor :: any).__index = GroupMotor

export type GroupMotor = Motor<GoalGroup, ValueGroup>

local function createGroupMotor(initialValues: ValueGroup): GroupMotor
	local states = {}

	for key, value in pairs(initialValues) do
		states[key] = {
			value = value,
			complete = true,
		}
	end

	local onComplete, fireOnComplete = createSignal()
	local onStep, fireOnStep = createSignal()

	local self = {
		__goals = {},
		__states = states,
		__allComplete = true,
		__onComplete = onComplete,
		__fireOnComplete = fireOnComplete,
		__onStep = onStep,
		__fireOnStep = fireOnStep,
		__running = false,
	}

	setmetatable(self, GroupMotor)

	return self :: any
end

function GroupMotor:start()
	if self.__running then
		return
	end

	self.__connection = AnimationStepSignal:Connect(function(dt)
		self:step(dt)
	end)

	self.__running = true
end

function GroupMotor:stop()
	if self.__connection ~= nil then
		self.__connection:Disconnect()
		self.__running = false
	end
end

function GroupMotor:step(dt: number)
	if self.__allComplete then
		return
	end

	local allComplete = true
	local values = {}

	for key, state in pairs(self.__states) do
		if not state.complete then
			local goal = self.__goals[key]

			if goal ~= nil then
				local maybeNewState = goal.step(state, dt)

				if maybeNewState ~= nil then
					state = maybeNewState
					self.__states[key] = maybeNewState
				end
			else
				state.complete = true
			end

			if not state.complete then
				allComplete = false
			end
		end

		values[key] = state.value
	end

	local wasAllComplete = self.__allComplete
	self.__allComplete = allComplete

	self.__fireOnStep(values)

	-- Check self.__allComplete as the motor may have been restarted in the onStep callback
	-- even if allComplete is true.
	-- Check self.__running in case the motor was stopped by onStep
	if self.__allComplete and not wasAllComplete and self.__running then
		self:stop()
		self.__fireOnComplete(values)
	end
end

function GroupMotor:setGoal(goals: GoalGroup)
	self.__goals = assign({}, self.__goals, goals)

	for key in pairs(goals) do
		local state = self.__states[key]

		if state == nil then
			error(("Cannot set goal for the value %s because it doesn't exist"):format(tostring(key)), 2)
		end

		state.complete = false
	end

	self.__allComplete = false
	self:start()
end

function GroupMotor:onStep(callback: MotorCallback<ValueGroup>)
	local subscription = self.__onStep:subscribe(callback)

	return function()
		subscription:unsubscribe()
	end
end

function GroupMotor:onComplete(callback: MotorCallback<ValueGroup>)
	local subscription = self.__onComplete:subscribe(callback)

	return function()
		subscription:unsubscribe()
	end
end

function GroupMotor:destroy()
	self:stop()
end

return createGroupMotor


--[[
	Copyright (c) 2023 Roblox Corporation

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict
export type Callback<T> = (T) -> ()
export type Subscription = {
	unsubscribe: (self: Subscription) -> (),
}

export type Signal<T> = {
	subscribe: (self: Signal<T>, callback: Callback<T>) -> Subscription,
}
export type FireSignal<T> = (T) -> ()

type InternalSubscription<T> = { callback: Callback<T>, unsubscribed: boolean }

local function createSignal<T>(): (Signal<T>, FireSignal<T>)
	local subscriptions: { [Callback<T>]: InternalSubscription<T> } = {}
	local suspendedSubscriptions = {}

	local firing = false

	local function subscribe(_self: Signal<T>, callback)
		local subscription = {
			callback = callback,
			unsubscribed = false,
		}

		-- If the callback is already registered, don't add to the
		-- suspendedConnection. Otherwise, this will disable the existing one.
		if firing and not subscriptions[callback] then
			suspendedSubscriptions[callback] = subscription
		else
			subscriptions[callback] = subscription
		end

		local function unsubscribe(_self: Subscription)
			subscription.unsubscribed = true
			subscriptions[callback] = nil
			suspendedSubscriptions[callback] = nil
		end

		return {
			unsubscribe = unsubscribe,
		}
	end

	local function fire(value: T)
		firing = true
		for callback, subscription in subscriptions do
			if not subscription.unsubscribed and not suspendedSubscriptions[callback] then
				callback(value)
			end
		end

		firing = false
		for callback, subscription in suspendedSubscriptions do
			subscriptions[callback] = subscription
		end
		table.clear(suspendedSubscriptions)
	end

	return {
		subscribe = subscribe,
	}, fire
end

return createSignal


--[[
	Copyright (c) 2018-2023 Roblox Corporation

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict
local AnimationStepSignal = require("./AnimationStepSignal")
local createSignal = require("./createSignal")

local types = require("./types")
type AnimationValue = types.AnimationValue
type Goal<T> = types.Goal<T>
type State = types.State
type Motor<T, U> = types.Motor<T, U>
type Unsubscribe = types.Unsubscribe
type MotorCallback<T> = types.MotorCallback<T>

type SingleMotorInternal = {
	__goal: Goal<any>,
	__state: State & any,
	__onComplete: createSignal.Signal<AnimationValue>,
	__fireOnComplete: createSignal.FireSignal<AnimationValue>,
	__onStep: createSignal.Signal<AnimationValue>,
	__fireOnStep: createSignal.FireSignal<AnimationValue>,
	__running: boolean,
	__connection: RBXScriptConnection?,

	start: (self: SingleMotorInternal) -> (),
	stop: (self: SingleMotorInternal) -> (),
	step: (self: SingleMotorInternal, dt: number) -> (),
	setGoal: (self: SingleMotorInternal, goal: Goal<any>) -> (),
	onStep: (self: SingleMotorInternal, callback: MotorCallback<AnimationValue>) -> Unsubscribe,
	onComplete: (self: SingleMotorInternal, callback: MotorCallback<AnimationValue>) -> Unsubscribe,
	destroy: (self: SingleMotorInternal) -> (),
}

local SingleMotor = {} :: SingleMotorInternal
(SingleMotor :: any).__index = SingleMotor

export type SingleMotor = Motor<Goal<any>, AnimationValue>

local function createSingleMotor(initialValue: AnimationValue): SingleMotor
	local onComplete, fireOnComplete = createSignal()
	local onStep, fireOnStep = createSignal()

	local self = {
		__goal = nil,
		__state = {
			value = initialValue,
			complete = true,
		},
		__onComplete = onComplete,
		__fireOnComplete = fireOnComplete,
		__onStep = onStep,
		__fireOnStep = fireOnStep,
		__running = false,
	}

	setmetatable(self, SingleMotor)

	return self :: any
end

function SingleMotor:start()
	if self.__running then
		return
	end

	self.__connection = AnimationStepSignal:Connect(function(dt)
		self:step(dt)
	end)

	self.__running = true
end

function SingleMotor:stop()
	if self.__connection ~= nil then
		self.__connection:Disconnect()
	end

	self.__running = false
end

function SingleMotor:step(dt: number)
	if self.__state.complete then
		return
	end

	if self.__goal == nil then
		return
	end

	local newState = self.__goal.step(self.__state, dt)

	if newState ~= nil then
		self.__state = newState
	end

	self.__fireOnStep(self.__state.value)

	if self.__state.complete and self.__running then
		self:stop()
		self.__fireOnComplete(self.__state.value)
	end
end

function SingleMotor:setGoal(goal)
	self.__goal = goal
	self.__state.complete = false
	self:start()
end

function SingleMotor:onStep(callback: MotorCallback<AnimationValue>)
	local subscription = self.__onStep:subscribe(callback)

	return function()
		subscription:unsubscribe()
	end
end

function SingleMotor:onComplete(callback: MotorCallback<AnimationValue>)
	local subscription = self.__onComplete:subscribe(callback)

	return function()
		subscription:unsubscribe()
	end
end

function SingleMotor:destroy()
	self:stop()
end

return createSingleMotor


--[[
	Copyright (c) 2018-2023 Roblox Corporation

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict
--[[
    Ease functions for creating smooth animations.

    Provides different easing functions like linear, quadratic, cubic, quartic, quintic, and more.
    Inspired by Robert Penner's easing functions.
    Supports Roblox EasingStyle.
]]
local types = require("./types")
type State = types.State
type Goal<T> = types.Goal<T>

export type EasingStyle = Enum.EasingStyle | { number }

export type EaseOptions = {
	-- Defaults to 1
	duration: number?,
	-- Defaults to Enum.EasingStyle.Linear
	easingStyle: EasingStyle?,
}

type EaseState = {
	elapsed: number?,
	goal: number?,
	initialValue: number?,
}

local function linear(t: number, _s: EasingStyle): number
	return t
end

local function quad(t: number, _s: EasingStyle): number
	return t * t
end

local function cubic(t: number, _s: EasingStyle): number
	return t * t * t
end

local function quart(t: number, _s: EasingStyle): number
	return t * t * t * t
end

local function quint(t: number, _s: EasingStyle): number
	return t * t * t * t * t
end

local function exponential(t: number, _s: EasingStyle): number
	return t == 0 and 0 or math.pow(2, 10 * (t - 1))
end

local function sine(t: number, _s: EasingStyle): number
	return 1 - math.cos((t * math.pi) / 2)
end

local function elastic(t: number, _s: EasingStyle): number
	return t == 0 and 0 or t == 1 and 1 or -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * ((2 * math.pi) / 3))
end

local function back(t: number, _s: EasingStyle): number
	local c1 = 1.70158
	local c3 = c1 + 1
	return c3 * t * t * t - c1 * t * t
end

local function easeOutBounce(t: number): number
	local n1 = 7.5625
	local d1 = 2.75

	if t < 1 / d1 then
		return n1 * t * t
	elseif t < 2 / d1 then
		return n1 * (t - 1.5 / d1) * (t - 1.5 / d1) + 0.75
	elseif t < 2.5 / d1 then
		return n1 * (t - 2.25 / d1) * (t - 2.25 / d1) + 0.9375
	else
		return n1 * (t - 2.625 / d1) * (t - 2.625 / d1) + 0.984375
	end
end

local function bounce(t: number, _s: EasingStyle): number
	return 1 - easeOutBounce(1 - t)
end

local function circular(t: number, _s: EasingStyle): number
	return -(math.sqrt(1 - t * t) - 1)
end

-- Function to evaluate cubic bezier x(t) or y(t) at time t
local function evaluateBezier(t: number, p1: number, p2: number): number
	local oneMinusT = 1 - t
	local oneMinusT2 = oneMinusT * oneMinusT
	local t2 = t * t

	return 3 * oneMinusT2 * t * p1 + 3 * oneMinusT * t2 * p2 + t * t2
end

local NUM_ITERATIONS = 10
local function cubicBezier(t: number, values: EasingStyle): number
	assert(typeof(values) == "table", "Expected values to be a table")
	assert(#values == 4, "Expected values to have 4 elements")

	local x1, y1, x2, y2 = table.unpack(values)

	assert(x1 >= 0 and x1 <= 1, "x1 must be between 0 and 1")
	assert(x2 >= 0 and x2 <= 1, "x2 must be between 0 and 1")

	-- Handle edge cases
	if t <= 0 then
		return 0
	end
	if t >= 1 then
		return 1
	end

	-- Binary search to find t value where x(t) = target
	local left = 0
	local right = 1
	local target = t
	local iterationCount = 0
	local finalError

	for _ = 1, NUM_ITERATIONS do
		iterationCount += 1
		local mid = (left + right) / 2
		local x = evaluateBezier(mid, x1, x2)
		finalError = math.abs(x - target)

		if finalError < 0.0001 then
			-- Found close enough match
			local y = evaluateBezier(mid, y1, y2)

			return y
		elseif x < target then
			left = mid
		else
			right = mid
		end
	end

	-- Use final midpoint
	local finalT = (left + right) / 2
	local y = evaluateBezier(finalT, y1, y2)

	return y
end

-- deviation: declare easing style table
local Enum: typeof(Enum) = {
	EasingStyle = {
		Linear = "Linear",
		Quad = "Quad",
		Cubic = "Cubic",
		Quart = "Quart",
		Quint = "Quint",
		Exponential = "Exponential",
		Sine = "Sine",
		Back = "Back",
		Bounce = "Bounce",
		Elastic = "Elastic",
		Circular = "Circular",
	},
} :: any

local easingFunctions = {
	[Enum.EasingStyle.Linear] = linear,
	[Enum.EasingStyle.Quad] = quad,
	[Enum.EasingStyle.Cubic] = cubic,
	[Enum.EasingStyle.Quart] = quart,
	[Enum.EasingStyle.Quint] = quint,
	[Enum.EasingStyle.Exponential] = exponential,
	[Enum.EasingStyle.Sine] = sine,
	[Enum.EasingStyle.Back] = back,
	[Enum.EasingStyle.Bounce] = bounce,
	[Enum.EasingStyle.Elastic] = elastic,
	[Enum.EasingStyle.Circular] = circular,
}

local function ease(goalPosition: number, inputOptions: EaseOptions?): Goal<EaseState>
	local duration = if inputOptions and inputOptions.duration then inputOptions.duration else 1
	local easingStyle = if inputOptions and inputOptions.easingStyle
		then inputOptions.easingStyle
		else Enum.EasingStyle.Linear

	local easingFunction: (number, Enum.EasingStyle | { number }) -> number = if typeof(easingStyle) == "EnumItem"
			or typeof(easingStyle) == "string"
		then easingFunctions[easingStyle]
		else cubicBezier

	local function step(state: State & EaseState, dt: number): State & EaseState
		local p0 = if state.initialValue ~= nil then state.initialValue else state.value or 0
		local elapsed = (state.elapsed or 0) + dt

		-- If the goalPosition changed, update initialValue
		if state.goal and goalPosition ~= state.goal :: number then
			p0 = state.value
			elapsed = 0
		end

		local t = math.min(elapsed / duration, 1)
		local easedT = easingFunction(t, easingStyle)

		local p1 = p0 + (goalPosition - p0) * easedT
		local complete = elapsed >= duration or p0 == goalPosition

		if complete then
			p1 = goalPosition
			-- Set these for accuracy in the next animation
			p0 = goalPosition
			elapsed = 0
		end

		return {
			initialValue = p0,
			goal = goalPosition,
			value = p1,
			elapsed = elapsed,
			complete = complete,
		}
	end

	return {
		step = step,
	}
end

return ease


--[[
	Copyright (c) 2018-2023 Roblox Corporation

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--!strict
export type MotorCallback<T> = (T) -> ()
export type Unsubscribe = () -> ()

-- Animation values need to be continuous and interpolatable; for now, that's
-- probably just numbers, but one could imagine something more sophisticated
export type AnimationValue = number

type Empty = {}
export type State = {
	value: AnimationValue,
	complete: boolean,
}

export type Goal<T = Empty> = {
	step: (state: State & T, dt: number) -> State & T,
}

export type Motor<T, U> = {
	start: (self: Motor<T, U>) -> (),
	stop: (self: Motor<T, U>) -> (),
	step: (self: Motor<T, U>, dt: number) -> (),
	setGoal: (self: Motor<T, U>, goal: T) -> (),
	onStep: (self: Motor<T, U>, callback: MotorCallback<U>) -> Unsubscribe,
	onComplete: (self: Motor<T, U>, callback: MotorCallback<U>) -> Unsubscribe,
	destroy: (self: Motor<T, U>) -> (),
}

return {}


local bench = require("./bench")

local ITERATIONS = 10000

do
	local tween = require("../packages/ripple/src/tween")
	local createTween = tween.createTween
	local scheduler = tween.scheduler

	bench("ripple", function()
		createTween(0, { start = true }):setGoal(1)

		for _ = 1, ITERATIONS do
			scheduler.step(1 / ITERATIONS)
		end

		scheduler.clear()
	end)
end

do
	local AnimationStepSignal = require("./otter/AnimationStepSignal")
	local createSingleMotor = require("./otter/createSingleMotor")
	local ease = require("./otter/ease")

	bench("otter", function()
		createSingleMotor(0):setGoal(ease(1))

		for _ = 1, ITERATIONS do
			AnimationStepSignal:Fire(1 / ITERATIONS)
		end

		AnimationStepSignal:Clear()
	end)
end


--[[
	MIT License

	Copyright (c) 2022 centau

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

local graph = require("./graph")
local create_node = graph.create_node
local assert_stable_scope = graph.assert_stable_scope
local evaluate_node = graph.evaluate_node

local function effect<T>(callback: (T) -> T, initial_value: T)
	local node = create_node(assert_stable_scope(), callback, initial_value)

	evaluate_node(node)
end

return effect :: (<T>(callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ())


--[[
	MIT License

	Copyright (c) 2022 centau

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

local function inline_test(): string
	return debug.info(1, "n")
end

local is_O2 = inline_test() ~= "inline_test"

return {
	strict = not is_O2,
	batch = false,
	defer_nested_properties = true,
}


--[[
	MIT License

	Copyright (c) 2022 centau

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--# selene: allow(unused_variable)
--# selene: allow(incorrect_standard_library_use)

local flags = require("./flags")

export type SourceNode<T> = {
	cache: T,
	[number]: Node<T>,
}

export type Node<T> = {
	cache: T,
	effect: ((T) -> T) | false,
	cleanups: { () -> () } | false,

	context: { [number]: unknown } | false,

	owned: { Node<T> } | false,
	owner: Node<T> | false,

	parents: { SourceNode<T> },
	[number]: Node<T>, -- children
}

local scopes = { n = 0 } :: { [number]: Node<any>, n: number } -- scopes stack

local function efn(err: string)
	local trace = debug.traceback(err, 2)

	if string.find(err, "^effect error stacktrace") then -- if effect error is nested
		trace = string.gsub("    " .. trace, "\n", function() -- indent entire error
			return "\n    "
		end)
	end

	trace ..= "\nsource update stacktrace:"
	return trace
end

local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string | U)
	local thread = coroutine.create(xpcall)
	--local function efn(err: string) return debug.traceback(err, 3) end
	local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg)

	assert(resume_ok)

	if coroutine.status(thread) ~= "dead" then
		return false, debug.traceback(thread, "attempt to yield in reactive scope")
	end

	return run_ok, result
end

local function get_scope(): Node<unknown>?
	return scopes[scopes.n]
end

local function assert_stable_scope(): Node<unknown>
	local scope = get_scope()

	if not scope then
		local caller_name = debug.info(2, "n")
		return error(`cannot use {caller_name}() outside a stable or reactive scope`, 0)
	elseif scope.effect then
		error("cannot create a new reactive scope inside another reactive scope", 0)
	end

	return scope
end

local function push_child<T>(parent: SourceNode<any>, child: Node<any>)
	table.insert(parent, child)
	table.insert(child.parents, parent)
end

local function push_scope<T>(node: Node<T>)
	local n = scopes.n + 1
	scopes.n = n
	scopes[n] = node
end

local function pop_scope()
	local n = scopes.n
	scopes.n = n - 1
	scopes[n] = nil
end

local function push_cleanup<T>(node: Node<T>, cleanup: () -> ())
	if node.cleanups then
		table.insert(node.cleanups, cleanup)
	else
		node.cleanups = { cleanup }
	end
end

local function flush_cleanups<T>(node: Node<T>)
	if node.cleanups then
		for _, fn in next, node.cleanups do
			local ok, err: string? = xpcall(fn, debug.traceback)
			if not ok then
				error(`cleanup error: {err}`, 0)
			end
		end

		table.clear(node.cleanups)
	end
end

local function find_and_swap_pop<T>(t: { T }, v: T)
	local i = table.find(t, v) :: number
	local n = #t
	t[i] = t[n]
	t[n] = nil
end

local function unparent<T>(node: Node<T>)
	local parents = node.parents

	for i, parent in parents do
		find_and_swap_pop(parent, node)
		parents[i] = nil
	end
end

local function destroy<T>(node: Node<T>)
	if flags.strict and table.find(scopes, node) then
		error("attempt to destroy an active scope", 0)
	end

	flush_cleanups(node)
	unparent(node)

	if node.owner then
		find_and_swap_pop(node.owner.owned :: { Node<T> }, node)
		node.owner = false
	end

	if node.owned then
		local owned = node.owned
		while owned[1] do
			destroy(owned[1])
		end
	end
end

local function destroy_owned<T>(node: Node<T>)
	if node.owned then
		local owned = node.owned
		while owned[1] do
			destroy(owned[1])
		end
	end
end

local update_queue = { n = 0 } :: { n: number, [number]: Node<any> }

local function evaluate_node<T>(node: Node<T>)
	if flags.strict then
		local initial_value = node.cache

		for i = 1, 2 do
			local cur_value = node.cache

			flush_cleanups(node)
			destroy_owned(node)

			push_scope(node)
			local ok, new_value = ycall(node.effect :: (T) -> T, cur_value)
			pop_scope()

			if not ok then
				table.clear(update_queue)
				update_queue.n = 0
				error(`effect error stacktrace\n{new_value :: string}`, 0)
			end

			node.cache = new_value :: T
		end

		return initial_value ~= node.cache
	else
		local cur_value = node.cache

		flush_cleanups(node)
		destroy_owned(node)

		push_scope(node)
		local ok, new_value = pcall(node.effect :: (T) -> T, node.cache)
		pop_scope()

		if not ok then
			table.clear(update_queue)
			update_queue.n = 0
			error(`effect error:\n{new_value}\n`, 0)
		end

		node.cache = new_value
		return cur_value ~= new_value
	end
end

local function queue_children_for_update<T>(node: SourceNode<T>)
	local i = update_queue.n
	while node[1] do
		i += 1
		update_queue[i] = node[1]
		unparent(node[1])
	end
	update_queue.n = i
end

local function get_update_queue_length()
	return update_queue.n
end

local function flush_update_queue(from: number)
	local i = from + 1
	while i <= update_queue.n do
		local node = update_queue[i]
		--assert(node.effect)

		if node.owner and evaluate_node(node) then
			queue_children_for_update(node)
		end

		update_queue[i] = false :: any
		i += 1
	end

	update_queue.n = from
end

local function update_descendants<T>(root: SourceNode<T>)
	local n0 = update_queue.n
	queue_children_for_update(root)

	if flags.batch then
		return
	end

	local i = n0 + 1
	while i <= update_queue.n do
		local node = update_queue[i]
		--assert(node.effect)

		-- check if node is still owned in case destroyed after queued
		if node.owner and evaluate_node(node) then
			queue_children_for_update(node)
		end

		update_queue[i] = false :: any -- false instead of nil to avoid sparse
		i += 1
	end

	update_queue.n = n0
end

local function push_child_to_scope<T>(node: SourceNode<T>)
	local scope = get_scope()
	if scope and scope.effect then -- do not track nodes with no effect
		push_child(node, scope)
	end
end

local function create_node<T>(owner: false | Node<any>, effect: false | (T) -> T, value: T): Node<T>
	local node: Node<T> = {
		cache = value,
		effect = effect,
		cleanups = false,

		context = false,

		owner = owner,
		owned = false,

		parents = {},
	}

	if owner then
		if owner.owned then
			table.insert(owner.owned, node)
		else
			owner.owned = { node }
		end
	end

	return node
end

local function create_source_node<T>(value: T): SourceNode<T>
	return { cache = value }
end

local function get_children<T>(node: Node<T>): { Node<unknown> }
	return { unpack(node) } :: { Node<any> }
end

local function set_context<T>(node: Node<T>, key: number, value: unknown)
	if node.context then
		node.context[key] = value
	else
		node.context = { [key] = value }
	end
end

return table.freeze({
	push_scope = push_scope,
	pop_scope = pop_scope,
	evaluate_node = evaluate_node,
	get_scope = get_scope,
	assert_stable_scope = assert_stable_scope,
	push_cleanup = push_cleanup,
	destroy = destroy,
	flush_cleanups = flush_cleanups,
	push_child_to_scope = push_child_to_scope,
	update_descendants = update_descendants,
	push_child = push_child,
	create_node = create_node,
	create_source_node = create_source_node,
	get_children = get_children,
	flush_update_queue = flush_update_queue,
	get_update_queue_length = get_update_queue_length,
	set_context = set_context,
	scopes = scopes,

	q = update_queue,
})


--[[
	MIT License

	Copyright (c) 2022 centau

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

--# selene: allow(unused_variable)
--# selene: allow(mismatched_arg_count)
--# selene: allow(shadowing)

local graph = require("./graph")
type Node<T> = graph.Node<T>
local create_node = graph.create_node
local push_scope = graph.push_scope
local pop_scope = graph.pop_scope
local destroy = graph.destroy

local refs = {}

local function root<T...>(fn: (destroy: () -> ()) -> T...): (() -> (), T...)
	local node = create_node(false, false, false)

	refs[node] = true -- prevent gc of root node

	local destroy = function()
		if not refs[node] then
			error("root already destroyed")
		end
		refs[node] = nil
		destroy(node)
	end

	push_scope(node)

	local result = { xpcall(fn, debug.traceback, destroy) }

	pop_scope()

	if not result[1] then
		destroy()
		error(`error while running root():\n\n{result[2]}`, 0)
	end

	return destroy, unpack(result :: any, 2)
end

return root :: <T...>(fn: (destroy: () -> ()) -> T...) -> (() -> (), T...)


--[[
	MIT License

	Copyright (c) 2022 centau

	Permission is hereby granted, free of charge, to any person obtaining a copy
	of this software and associated documentation files (the "Software"), to deal
	in the Software without restriction, including without limitation the rights
	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
	copies of the Software, and to permit persons to whom the Software is
	furnished to do so, subject to the following conditions:

	The above copyright notice and this permission notice shall be included in all
	copies or substantial portions of the Software.

	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
]]

local graph = require("./graph")
type Node<T> = graph.Node<T>
local create_source_node = graph.create_source_node
local push_child_to_scope = graph.push_child_to_scope
local update_descendants = graph.update_descendants

export type Source<T> = (() -> T) & ((value: T) -> T)

local function source<T>(initial_value: T): Source<T>
	local node = create_source_node(initial_value)

	local function update_source(...): T
		if select("#", ...) == 0 then -- no args were given
			push_child_to_scope(node)
			return node.cache
		end

		local v = ... :: T
		if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then
			return v
		end

		node.cache = v
		update_descendants(node)
		return v
	end

	return update_source
end

return source :: (<T>(initial_value: T) -> Source<T>) & (<T>() -> Source<T>)


type CoreReactBinding<T> = {
	getValue: (self: CoreReactBinding<T>) -> T,
	_source: string?,
}

type ReactBindingMap = {
	map: <T, U>(self: CoreReactBinding<T> & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding<U>,
}

export type Binding<T> = CoreReactBinding<T> & ReactBindingMap

return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)


local Ripple = require(script.Parent.Parent.ripple.src)

export type Animatable = Ripple.Animatable
export type Easing = Ripple.Easing
export type Motion<T = any> = Ripple.Motion<T>
export type MotionOptions<T = any> = Ripple.MotionOptions<T>
export type Spring<T = any> = Ripple.Spring<T>
export type SpringOptions<T = any> = Ripple.SpringOptions<T>
export type Tween<T = any> = Ripple.Tween<T>
export type TweenOptions<T = any> = Ripple.TweenOptions<T>

return Ripple


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ReactGlobals = require(ReplicatedStorage["@rbxts-js"].ReactGlobals)

ReactGlobals.__ROACT_17_MOCK_SCHEDULER__ = true

local ReactRoblox = require(ReplicatedStorage["@rbxts-js"].ReactRoblox)

local mountedContainers = {}

local function render(element: any)
	local container = Instance.new("Folder")
	local root = ReactRoblox.createLegacyRoot(container)

	mountedContainers[container] = root

	ReactRoblox.act(function()
		root:render(element)
	end)

	return {
		unmount = function()
			ReactRoblox.act(function()
				root:unmount()
			end)
		end,
	}
end

local function cleanup()
	for _, root in mountedContainers do
		ReactRoblox.act(function()
			root:unmount()
		end)
	end
	table.clear(mountedContainers)
end

return {
	render = render,
	cleanup = cleanup,
}


local React = require("../../React")
local Ripple = require("../../Ripple")
local helpers = require("./helpers")
local test = require("../../../../tests/test")
local useMotion = require("../useMotion")

local render = helpers.render
local cleanup = helpers.cleanup

test.afterEach(function()
	cleanup()
end)

test("should update binding when goal is set in layout effect", function()
	local binding, motion

	local function LayoutMotion()
		binding, motion = useMotion(0)

		React.useLayoutEffect(function()
			motion:setGoal(1, { tween = { duration = 0 } })
		end, {})
	end

	render(React.createElement(LayoutMotion))

	Ripple.motionScheduler.step(1)
	assert(binding:getValue() == 1, `Expected motion to reach goal, got {binding:getValue()}`)
end)

return {}


local React = require("../../React")
local Ripple = require("../../Ripple")
local helpers = require("./helpers")
local test = require("../../../../tests/test")
local useTween = require("../useTween")

local render = helpers.render
local cleanup = helpers.cleanup

test.afterEach(function()
	cleanup()
end)

test("should update binding when goal is set in layout effect", function()
	local binding, tween

	local function LayoutTween()
		binding, tween = useTween(0)

		React.useLayoutEffect(function()
			tween:setGoal(1, { duration = 0 })
		end, {})
	end

	render(React.createElement(LayoutTween))

	Ripple.tweenScheduler.step(1)
	assert(binding:getValue() == 1, `Expected tween to reach goal, got {binding:getValue()}`)
end)

return {}


local React = require(script.Parent.Parent.React)
local Ripple = require(script.Parent.Parent.Ripple)

type Binding<T> = React.Binding<T>
type Animatable = Ripple.Animatable
type Motion<T> = Ripple.Motion<T>
type MotionOptions<T> = Ripple.MotionOptions<T>

local function useMotion<T>(initialValue: T, initialOptions: MotionOptions<T>?): (Binding<T>, Motion<T>)
	local value, setValue = React.useBinding(initialValue)
	local motion = React.useRef((nil :: any) :: Motion<T>)

	if not motion.current then
		motion.current = Ripple.createMotion(initialValue, initialOptions)
	end

	React.useEffect(function()
		if not (initialOptions and initialOptions.start == false) then
			motion.current:start()
		end

		local disconnect = motion.current:onChange(setValue)

		return function()
			disconnect()
			motion.current:stop()
			motion.current = nil :: never
		end
	end, {})

	return value, motion.current
end

return useMotion


local React = require(script.Parent.Parent.React)
local Ripple = require(script.Parent.Parent.Ripple)

type Binding<T> = React.Binding<T>
type Animatable = Ripple.Animatable
type Tween<T> = Ripple.Tween<T>
type TweenOptions<T> = Ripple.TweenOptions<T>

local function useTween<T>(initialValue: T, initialOptions: TweenOptions<T>?): (Binding<T>, Tween<T>)
	local value, setValue = React.useBinding(initialValue)
	local tween = React.useRef((nil :: any) :: Tween<T>)

	if not tween.current then
		tween.current = Ripple.createTween(initialValue, initialOptions)
	end

	React.useEffect(function()
		if not (initialOptions and initialOptions.start == false) then
			tween.current:start()
		end

		local disconnect = tween.current:onChange(setValue)

		return function()
			disconnect()
			tween.current:stop()
			tween.current = nil :: never
		end
	end, {})

	return value, tween.current
end

return useTween


local easing = require("../easing")
local test = require("../../../../tests/test")

test("should have correct start and end", function()
	for name, easingFunction in pairs(easing) do
		local x0 = easingFunction(0)
		local x1 = easingFunction(1)
		assert(math.abs(x0) < 1e-4, `{name}(0) should be near 0, got {x0}`)
		assert(math.abs(1 - x1) < 1e-4, `{name}(1) should be near 1, got {x1}`)
	end
end)

test("should be within range", function()
	for name, easingFunction in pairs(easing) do
		for alpha = 0, 1, 0.1 do
			local x = easingFunction(alpha)
			assert(x < 1.5, `{name}({alpha}) should be less than 1.5, got {x}`)
			assert(x > -0.5, `{name}({alpha}) should be greater than -0.5, got {x}`)
		end
	end
end)

return {}


local test = require("../../../tests/test")

test.describe("spring", function()
	require("@self/spring.test")
end)

test.describe("tween", function()
	require("@self/tween.test")
end)

test.describe("motion", function()
	require("@self/motion.test")
end)

test.describe("easing", function()
	require("@self/easing.test")
end)

test.describe("utils", function()
	test.describe("interpolate", function()
		require("@self/utils/interpolate.test")
	end)

	test.describe("oklab", function()
		require("@self/utils/oklab.test")
	end)
end)

return {}


local easing = require("../easing")
local motion = require("../motion")
local test = require("../../../../tests/test")

local createMotion = motion.createMotion
local scheduler = motion.scheduler

test("should have expected api", function()
	local motion = createMotion(0)

	assert(type(motion.setPosition) == "function", "missing setPosition")
	assert(type(motion.setVelocity) == "function", "missing setVelocity")
	assert(type(motion.setGoal) == "function", "missing setGoal")

	assert(type(motion.getPosition) == "function", "missing getPosition")
	assert(type(motion.getVelocity) == "function", "missing getVelocity")
	assert(type(motion.getGoal) == "function", "missing getGoal")

	assert(type(motion.onChange) == "function", "missing onChange")
	assert(type(motion.onComplete) == "function", "missing onComplete")

	assert(type(motion.step) == "function", "missing step")
	assert(type(motion.spring) == "function", "missing spring")
	assert(type(motion.tween) == "function", "missing tween")
	assert(type(motion.idle) == "function", "missing idle")
	assert(type(motion.configure) == "function", "missing configure")

	assert(type(motion.start) == "function", "missing start")
	assert(type(motion.stop) == "function", "missing stop")
	assert(type(motion.destroy) == "function", "missing destroy")
end)

test("should swap from spring to tween", function()
	local completions = 0
	local motion = createMotion(vector.one)

	motion:onComplete(function()
		completions += 1
	end)
	motion:spring(vector.zero, { precision = 0.1 })
	motion:step(0.1)
	assert(completions == 0, "motion should be incomplete")
	assert(motion:getPosition() ~= vector.one, "position should change")
	assert(motion:getVelocity() ~= vector.zero, "velocity should not be zero")

	motion:step(0.9)
	assert(completions == 1, "motion should be complete")
	assert(motion:getPosition() == vector.zero, "position should finish")
	assert(motion:getVelocity() == vector.zero, "velocity should be zero after completion")

	motion:tween(vector.one, { duration = 1 })
	motion:step(0.5)
	assert(completions == 1, "motion should be incomplete")
	assert(motion:getPosition() == vector.create(0.5, 0.5, 0.5), "position should be halfway to goal")
	assert(motion:getVelocity() == vector.zero, "velocity should be zero during tween")

	motion:step(0.5)
	assert(completions == 2, "motion should be complete")
	assert(motion:getPosition() == vector.one, "position should finish at goal")
	assert(motion:getVelocity() == vector.zero, "velocity should be zero after completion")
end)

test("should swap from tween to spring", function()
	local completions = 0
	local motion = createMotion(vector.zero)

	motion:onComplete(function()
		completions += 1
	end)
	motion:tween(vector.one, { duration = 1 })
	motion:step(0.5)
	assert(completions == 0, "motion should be incomplete")
	assert(motion:getPosition() == vector.create(0.5, 0.5, 0.5), "position should be halfway to goal")
	assert(motion:getVelocity() == vector.zero, "velocity should be zero during tween")

	motion:step(0.5)
	assert(completions == 1, "motion should be complete")
	assert(motion:getPosition() == vector.one, "position should finish at goal")
	assert(motion:getVelocity() == vector.zero, "velocity should be zero after completion")

	motion:spring(vector.zero, { precision = 0.1 })
	motion:step(0.1)
	assert(completions == 1, "motion should be incomplete")
	assert(motion:getPosition() ~= vector.one, "position should change")
	assert(motion:getVelocity() ~= vector.zero, "velocity should not be zero")

	motion:step(0.9)
	assert(completions == 2, "motion should be complete")
	assert(motion:getPosition() == vector.zero, "position should finish")
	assert(motion:getVelocity() == vector.zero, "velocity should be zero after completion")
end)

test.describe("spring", function()
	test("should get properties", function()
		local spring = createMotion(vector.one)

		assert(spring:getPosition() == vector.one, "invalid position")
		assert(spring:getVelocity() == vector.zero, "invalid velocity")
		assert(spring:getGoal() == vector.one, "invalid target")
		assert(spring:idle(), "invalid complete")
	end)

	test("should update properties", function()
		local spring = createMotion(vector.zero)

		spring:setPosition(vector.one)
		spring:setVelocity(vector.one)
		spring:setGoal(vector.one)

		assert(spring:getPosition() == vector.one, "invalid position")
		assert(spring:getVelocity() == vector.one, "invalid velocity")
		assert(spring:getGoal() == vector.one, "invalid target")
		assert(not spring:idle(), "invalid complete")
	end)

	test("should handle tables", function()
		local spring = createMotion({ 0, 0, 0 })
		local initialPosition = spring:getPosition()
		local initialVelocity = spring:getVelocity()

		spring:setPosition({ 0, 0, 0 })
		spring:setVelocity({ 0, 0, 0 })

		assert(spring:getPosition() == initialPosition, "unnecessary position copy")
		assert(spring:getVelocity() == initialVelocity, "unnecessary velocity copy")

		spring:setPosition({ [1] = 1, [3] = 1 })
		spring:setVelocity({ [1] = 1, [3] = 1 })

		assert(table.concat(spring:getPosition()) == "101", "did not merge position")
		assert(table.concat(spring:getVelocity()) == "101", "did not merge velocity")
	end)

	test("should complete if goal is in reach", function()
		local result
		local spring = createMotion(vector.one, { spring = { precision = 0.5, restVelocity = 0.5 } })

		spring:onComplete(function(value)
			result = value
		end)
		spring:setPosition(vector.zero)
		spring:step(0)

		assert(not spring:idle(), "invalid complete")
		assert(not result, "invalid result")
		assert(spring:getPosition() == vector.zero, "invalid position")
		assert(spring:getVelocity() == vector.zero, "invalid velocity")

		spring:setPosition(vector.one / 2)
		spring:setVelocity(vector.one / 2)
		spring:step(0)

		assert(spring:idle(), "invalid complete")
		assert(result == vector.one, "invalid result")
		assert(spring:getPosition() == vector.one, "invalid position")
		assert(spring:getVelocity() == vector.zero, "invalid velocity")
	end)

	test("should connect while active", function()
		local springs = {}
		for index = 1, 10 do
			springs[index] = createMotion(vector.zero, { start = true, spring = { precision = 0.1 } })
		end
		assert(#scheduler.states == 0, "start with 0 connections")

		for _ = 1, 2 do
			for _, spring in springs do
				spring:setGoal(spring:getGoal() + vector.one)
			end
			assert(#scheduler.states == #springs, "connect on update")

			scheduler.step(0.2)
			assert(#scheduler.states == #springs, "stay connected on partial step")

			scheduler.step(0.5)
			assert(#scheduler.states == 0, "disconnect on complete")
		end
	end)

	test("should wake after update on complete", function()
		local changed = false
		local completes = 0

		local spring = createMotion(0, { spring = { precision = 0.1 } })
		spring:setGoal(1)
		spring:onChange(function()
			changed = true
		end)
		spring:onComplete(function()
			completes += 1
			if spring:getGoal() ~= 0 then
				spring:setGoal(0)
				assert(not spring:idle(), "did not uncomplete")
			end
		end)

		spring:step(1)
		assert(changed, "did not step")
		assert(completes == 1, "did not complete")
		assert(not spring:idle(), "did not uncomplete")

		changed = false
		spring:step(0.2)
		assert(changed, "did not step")
		assert(completes == 1, "completed for wrong goal")

		changed = false
		spring:step(0.2)
		assert(changed, "did not step")
		assert(completes == 2, "did not finally complete")
		assert(spring:idle(), "did not complete")

		changed = false
		spring:step(1)
		assert(not changed, "stepped after complete")
		assert(completes == 2, "completed after completion")
	end)

	test("should fire change with full state", function()
		local state = {}
		local spring = createMotion({ 0, 0, 0 })
		spring:onChange(function(newState)
			state = newState
		end)

		spring:setPosition({ 1, 1, 1 })
		assert(table.concat(state) == "111", "did not fire change with position")

		spring:setPosition({ [1] = 2, [3] = 2 })
		assert(table.concat(state) == "212", "did not fire change with position merge")
	end)
end)

test.describe("tween", function()
	test("should get properties", function()
		local tween = createMotion(vector.one, { tween = {} })

		assert(tween:getPosition() == vector.one, "invalid position")
		assert(tween:getGoal() == vector.one, "invalid target")
		assert(tween:idle(), "invalid complete")
	end)

	test("should update properties", function()
		local tween = createMotion(vector.zero, { tween = {} })

		tween:setPosition(vector.one)
		tween:setGoal(vector.one)

		assert(tween:getPosition() == vector.one, "invalid position")
		assert(tween:getGoal() == vector.one, "invalid target")
		assert(not tween:idle(), "invalid complete")
	end)

	test("should handle tables", function()
		local tween = createMotion({ 0, 0, 0 }, { tween = {} })
		local initialPosition = tween:getPosition()
		local initialGoal = tween:getGoal()

		tween:setPosition({ 0, 0, 0 })
		tween:setGoal({ 0, 0, 0 })

		assert(tween:getPosition() == initialPosition, "unnecessary position copy")
		assert(tween:getGoal() == initialGoal, "unnecessary goal copy")

		tween:setPosition({ [1] = 1, [3] = 1 })
		tween:setGoal({ [1] = 1, [3] = 1 })

		assert(table.concat(tween:getPosition()) == "101", "did not merge position")
		assert(table.concat(tween:getGoal()) == "101", "did not merge goal")
	end)

	test("should complete if duration passes", function()
		local result
		local tween = createMotion(vector.one, { tween = { duration = 1 } })

		tween:onComplete(function(value)
			result = value
		end)
		tween:setPosition(vector.zero)
		tween:step(0.5)

		assert(not tween:idle(), "invalid complete")
		assert(not result, "invalid result")
		assert(tween:getPosition() == vector.one / 2, "invalid position")

		tween:step(0.5)

		assert(tween:idle(), "invalid complete")
		assert(result == vector.one, "invalid result")
		assert(tween:getPosition() == vector.one, "invalid position")
	end)

	test("should start from new position", function()
		local result
		local tween = createMotion(vector.one, { tween = { duration = 1 } })

		tween:onComplete(function(value)
			result = value
		end)

		for _ = 1, 2 do
			tween:setPosition(vector.zero)
			tween:step(0.5)

			assert(not tween:idle(), "invalid complete")
			assert(not result, "invalid result")
			assert(tween:getPosition() == vector.one / 2, "invalid position")
		end

		tween:step(0.5)

		assert(tween:idle(), "invalid complete")
		assert(result == vector.one, "invalid result")
		assert(tween:getPosition() == vector.one, "invalid position")
	end)

	test("should connect while active", function()
		local tweens = {}
		for index = 1, 10 do
			tweens[index] = createMotion(vector.zero, { start = true, tween = { duration = 1 } })
		end
		assert(#scheduler.states == 0, "start with 0 connections")

		for _ = 1, 2 do
			for _, tween in tweens do
				tween:setGoal(tween:getGoal() + vector.one)
			end
			assert(#scheduler.states == #tweens, "connect on update")

			scheduler.step(0.5)
			assert(#scheduler.states == #tweens, "stay connected on partial step")

			scheduler.step(0.5)
			assert(#scheduler.states == 0, "disconnect on complete")
		end
	end)

	test("should repeat", function()
		local cases = {
			[3] = { 0.5, 0, 0.5, 0, 0.5, 1 },
			[4] = { 0.5, 0, 0.5, 0, 0.5, 0, 0.5, 1 },
		}
		local result
		local tween = createMotion(vector.one, { tween = { duration = 1 } })

		tween:onComplete(function(value)
			result = value
		end)

		for repeats, case in cases do
			tween:configure({ tween = { position = vector.zero, repeats = repeats } })

			for i = 1, repeats * 2 do
				tween:step(0.5)
				assert(
					tween:getPosition() == vector.one * case[i],
					`{repeats} expected {case[i]}, got {tween:getPosition()}`
				)
			end

			assert(tween:idle(), "did not complete")
			assert(result == vector.one * case[#case], `{repeats} expected {case[#case]}, got {result}`)
		end
	end)

	test("should repeat in reverse", function()
		local cases = {
			[3] = { 0.5, 1, 0.5, 0, 0.5, 1 },
			[4] = { 0.5, 1, 0.5, 0, 0.5, 1, 0.5, 0 },
		}
		local result
		local tween = createMotion(vector.one, { tween = { duration = 1 } })

		tween:onComplete(function(value)
			result = value
		end)

		for repeats, case in cases do
			tween:configure({ tween = { position = vector.zero, repeats = repeats, reverses = true } })

			for i = 1, repeats * 2 do
				tween:step(0.5)
				assert(
					tween:getPosition() == vector.one * case[i],
					`{repeats} expected {case[i]}, got {tween:getPosition()}`
				)
			end

			assert(tween:idle(), "did not complete")
			assert(result == vector.one * case[#case], `{repeats} expected {case[#case]}, got {result}`)
		end
	end)

	test("should wake after update on complete", function()
		local changes = 0
		local completes = 0

		local tween = createMotion(0, { tween = { duration = 1 } })
		tween:setGoal(1)
		tween:onChange(function()
			changes += 1
		end)
		tween:onComplete(function()
			completes += 1
			if tween:getGoal() ~= 0 then
				tween:setGoal(0)
				assert(not tween:idle(), "did not uncomplete")
			end
		end)

		tween:step(0.5)
		assert(changes == 1, "did not step")
		assert(completes == 0, "completed early")

		tween:step(0.5)
		assert(changes == 2, "did not step")
		assert(completes == 1, "did not complete")

		tween:step(0.5)
		assert(changes == 3, "did not step")
		assert(completes == 1, "completed early")

		tween:step(0.5)
		assert(changes == 4, "did not step")
		assert(completes == 2, "did not finally complete")
		assert(tween:idle(), "did not complete")

		tween:step(0.5)
		assert(changes == 4, "stepped after complete")
		assert(completes == 2, "completed after complete")
	end)

	test("should have correct start and end", function()
		local tween = createMotion(1, { tween = {} })

		for name in pairs(easing) do
			tween:configure({ tween = { position = 0, easing = name } })
			local x0 = tween:step(0)
			local x1 = tween:step(1)
			assert(x0 == 0, `{name}(0) should be 0, got {x0}`)
			assert(x1 == 1, `{name}(1) should be 1, got {x1}`)
		end
	end)

	test("should fire change with full state", function()
		local state = {}
		local tween = createMotion({ 0, 0, 0 }, { tween = {} })
		tween:onChange(function(newState)
			state = newState
		end)

		tween:setPosition({ 1, 1, 1 })
		assert(table.concat(state) == "111", "did not fire change with position")

		tween:setPosition({ [1] = 2, [3] = 2 })
		assert(table.concat(state) == "212", "did not fire change with position merge")
	end)
end)

return {}