local testkit = require("@testkit")
local cross = require("./init")

local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()

TEST("cross", function()
	do CASE("check globals")
		CHECK(
			task.cancel == cross.cancel and
			task.spawn == cross.spawn and
			task.defer == cross.defer and
			task.wait == cross.wait and
			_RUNTIME == cross.runtime and
			warn == cross.warn
		)
	end

	do CASE("spawn")
		local args_str: string
		local thread_started = false

		local func_thread = cross.spawn(function()
			thread_started = true
			local args = { coroutine.yield() }
			args_str = table.concat(args, ", ")
		end)

		while not thread_started do
			task.wait()
		end

		local thread_thread = cross.spawn(
			func_thread, "meow", "mrrp"
		)

		CHECK(
			type(func_thread) == "thread" and
			type(thread_thread) == "thread" and
			func_thread == thread_thread and
			args_str == "meow, mrrp"
		)
	end

	do CASE("defer")
		local thread_started = false
		local args_str: string

		local func_thread = cross.defer(function()
			thread_started = true
			local args = { coroutine.yield() }
			args_str = table.concat(args, ", ")
		end)

		while not thread_started do
			task.wait()
		end
		local thread_thread = cross.defer(
			func_thread, "meow", "mrrp"
		)

		while not args_str do
			task.wait()
		end

		CHECK(
			type(func_thread) == "thread" and
			type(thread_thread) == "thread" and
			func_thread == thread_thread and
			args_str == "meow, mrrp"
		)
	end

	do CASE("delay")
		--[[
		local thread_started = false
		local args_str: string

		local func_thread = cross.delay(1, function()
			thread_started = true
			local args = { coroutine.yield() }
			args_str = table.concat(args, ", ")
		end)

		if thread_started then
			CHECK(false)
		end

		task.wait(2)

		if not thread_started then
			print("lmao")
			CHECK(false)
		end

		-- for some reason after this args str doesn't ever get set
		-- even tho it should be, so once again ive lost to the hell that is the lune scheduler
		local thread_thread = cross.delay(
			1, func_thread, "meow", "mrrp"
		)

		if args_str then
			CHECK(false)
		end

		task.wait(2)

		CHECK(
			type(func_thread) == "thread" and
			type(thread_thread) == "thread" and
			func_thread == thread_thread and
			args_str == "meow, mrrp"
		)
		--]]
	end

	do CASE("cancel")
		local thread = cross.defer(function()
			cross.wait(4)
		end)

		cross.cancel(thread)
		CHECK(coroutine.status(thread) == "dead")
	end

end)


--!native

--[[
	warn
	pure luau version of roblox's warn global
]]

local function warn(...: unknown)
	print(`\27[0;33m{table.concat({ ... }, " ")}\27[0m`)
end

return warn


--!optimize 2
--!native

--[[
	Grouper
	Used for getting the latest group rank or role of a player on the server,
	and reducing groups api requests as only the server will make the requests
	and then send down the rank & role for a player.
	Rather than using Player:GetRankInGroup() on the client,
	and end up constantly making api requests as Player:GetRankInGroup()
	only caches its result on the server
]]

local GroupService = game:GetService("GroupService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")

type FireRankChanged = (send_to_player: Player, player_whose_rank_changed: Player, rank: number, role: string) -> ()

type FireAllRankChanged = (player_whose_rank_changed: Player, rank: number, role: string) -> ()

type ReplicateRankUpdateCallback = (player: Player, rank: number, role: string) -> ()

type RankChangedCallback = (player: Player, new_rank: number, old_rank: number) -> ()

type Groups = { { Role: string, Rank: number, Id: number } }

type FetchInfo = {
	last_fetched: number,
	is_fetching: boolean,
	rank: number,
}

export type ClientConfig = {
	connect_rank_changed: (callback: ReplicateRankUpdateCallback) -> ()
}

export type ServerConfig = {
	rank_refresh_rate: number?,
	fire_all_rank_changed: nil,
	fire_rank_changed: nil,
	groupid: number,
} |  {
	fire_all_rank_changed: FireAllRankChanged,
	fire_rank_changed: FireRankChanged,
	rank_refresh_rate: number?,
	groupid: number,
}

local GET_GROUPS_ASYNC = GroupService.GetGroupsAsync
local FIRE_ALL_RANK_CHANGED: FireAllRankChanged
local NOOP = function<T...>(...: T...) end
local FIRE_RANK_CHANGED: FireRankChanged
local IS_SERVER = RunService:IsServer()
local RANK_REFRESH_RATE: number
local DEFAULT_ROLE = "Guest"
local INITIATED = false
local DEFAULT_RANK = 0
local GROUP_ID: number

local ROLE_HASH_CACHE = { [0] = DEFAULT_ROLE } :: { [number]: string }
local THREADS_WAITING_FOR_PLAYERS = {} :: { [Player]: { thread } }
local RANK_CHANGED_CALLBACKS = {} :: { RankChangedCallback }
local PLAYER_FETCH_INFO = {} :: { [Player]: FetchInfo }
local ROLE_CACHE = table.create(256, DEFAULT_ROLE)
local PLAYER_RANKS = {} :: { [Player]: number }

-- cursed using the first argument to tell if it was sucessful or not
-- but hey it works so
local function FETCH_RANK_AND_ROLE(userid: number): (number?, string)
	local success, groups: Groups = pcall(GET_GROUPS_ASYNC, GroupService, userid)

	if success then
		for _, group in groups do
			if group.Id ~= GROUP_ID then continue end
			local role = group.Role
			local rank = group.Rank

			ROLE_HASH_CACHE[rank] = role
			ROLE_CACHE[rank + 1] = role
			return rank, role
		end

		-- player isn't in group, so we return 0 (the rank of a player not in the group)
		return DEFAULT_RANK, DEFAULT_ROLE
	else
		warn(`[GROUPER] couldn't fetch rank for user {userid}\n\tgroup-service-err: {groups}`)
		return nil, DEFAULT_ROLE
	end
end

-- having this yield, as in studio the rank isnt gotten immediatly like in-game
-- so this ends up returning 0
local function GET_RANK(player: Player): number
	local rank = PLAYER_RANKS[player]

	if rank then
		return rank
	elseif player and player:IsDescendantOf(Players) then
		local threads_waiting = THREADS_WAITING_FOR_PLAYERS[player]

		if threads_waiting then
			table.insert(threads_waiting, coroutine.running())
		else
			THREADS_WAITING_FOR_PLAYERS[player] = { coroutine.running() }
		end
		return coroutine.yield()
	else
		error("[GROUPER] player has left game too early")
	end
end

local function fetcher(player: Player, fetchinfo: FetchInfo)
	local new_rank, new_role = FETCH_RANK_AND_ROLE(player.UserId)

	if not player or not PLAYER_RANKS[player] then
		return
	end

	local rank = fetchinfo.rank

	if new_rank and new_rank ~= rank then
		PLAYER_RANKS[player] = new_rank
		fetchinfo.rank = new_rank

		for _, f in RANK_CHANGED_CALLBACKS do
			task.spawn(f, player, new_rank, rank)
		end
		FIRE_ALL_RANK_CHANGED(player, new_rank, new_role)
	end

	fetchinfo.last_fetched = os.time()
	fetchinfo.is_fetching = false
end

local function on_player_removing(player: Player)
	local threads_waiting = THREADS_WAITING_FOR_PLAYERS[player]

	if threads_waiting then
		THREADS_WAITING_FOR_PLAYERS[player] = nil

		for _, thread in threads_waiting do
			-- this is such an extreme edge case that will most likely never happen
			-- and if it does, most code where ur checking for ranks isnt that critical
			-- ie code that uses grouper doesnt deal w loading datastores ect ect
			coroutine.close(thread)
		end
	end
	PLAYER_FETCH_INFO[player] = nil
	PLAYER_RANKS[player] = nil
end

local function on_player_added(player: Player)
	-- idc if this thread errors if the player leaves, its gonna be rare if that happens
	-- so if it does oh well, atleast i didnt create a new closure
	task.spawn(function(send_to_player: Player)
		for player, rank in PLAYER_RANKS do
			FIRE_RANK_CHANGED(send_to_player, player, rank, ROLE_CACHE[rank + 1])
		end
	end, player)

	local userid = player.UserId
	local fetch_time = os.clock()
	local rank, role = FETCH_RANK_AND_ROLE(userid)
	local rank = rank or 0

	if player and player:IsDescendantOf(Players) then
		local threads_waiting = THREADS_WAITING_FOR_PLAYERS[player]
		PLAYER_RANKS[player] = rank

		FIRE_ALL_RANK_CHANGED(player, rank, role)

		if threads_waiting then
			for _, thread in threads_waiting do
				task.spawn(thread, rank)
			end
			THREADS_WAITING_FOR_PLAYERS[player] = nil
		end

		PLAYER_FETCH_INFO[player] = {
			last_fetched = fetch_time,
			is_fetching = false,
			rank = rank
		}
	end
end

local function fetch_loop()
	for player, fetchinfo in PLAYER_FETCH_INFO do
		if
			os.time() - fetchinfo.last_fetched >= RANK_REFRESH_RATE and
			not fetchinfo.is_fetching
		then
			fetchinfo.is_fetching = true
			task.defer(fetcher, player, fetchinfo)
		end
	end
end

local grouper = { rank = GET_RANK, init = {} }

function grouper.init.client(config: ClientConfig)
	if IS_SERVER then
		error("[GROUPER] can only call client_init on the client")
	end
	if INITIATED then
		error("[GROUPER] cannot initiate twice")
	end
	INITIATED = true

	config.connect_rank_changed(function(player, rank, role)
		local threads_waiting = THREADS_WAITING_FOR_PLAYERS[player]
		local old_rank = PLAYER_RANKS[player]

		PLAYER_RANKS[player] = rank
		ROLE_CACHE[rank + 1] = role

		if threads_waiting then
			for _, thread in threads_waiting do
				task.spawn(thread, rank)
			end
			THREADS_WAITING_FOR_PLAYERS[player] = nil
		end

		for _, callback in RANK_CHANGED_CALLBACKS do
			task.spawn(callback, player, rank, old_rank)
		end
	end)

	Players.PlayerRemoving:Connect(on_player_removing)
end

function grouper.init.server(config: ServerConfig)
	if not IS_SERVER then
		error("[GROUPER] can only call server_init on the server")
	end
	if INITIATED then
		error("[GROUPER] cannot initiate twice")
	end

	-- replication is optional if someone isnt using it
	FIRE_ALL_RANK_CHANGED = config.fire_all_rank_changed or NOOP
	FIRE_RANK_CHANGED = config.fire_rank_changed or NOOP
	RANK_REFRESH_RATE = config.rank_refresh_rate or 30
	GROUP_ID = config.groupid
	INITIATED = true

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

	Players.PlayerRemoving:Connect(on_player_removing)
	RunService.PostSimulation:Connect(fetch_loop)
	Players.PlayerAdded:Connect(on_player_added)
end

function grouper.on_rank_changed(callback: RankChangedCallback): () -> ()
	table.insert(RANK_CHANGED_CALLBACKS, callback)

	return function()
		local index = table.find(RANK_CHANGED_CALLBACKS, callback)

		if index then
			if index == 1 then
				RANK_CHANGED_CALLBACKS[1] = nil
			else
				local len = #RANK_CHANGED_CALLBACKS
				RANK_CHANGED_CALLBACKS[index] = RANK_CHANGED_CALLBACKS[len]
				RANK_CHANGED_CALLBACKS[len] = nil
			end
		end
	end
end

function grouper.rank_and_role(player: Player): (number, string)
	local rank = GET_RANK(player)
	return rank, ROLE_CACHE[rank + 1]
end

function grouper.is_in_group(player: Player): boolean
	return GET_RANK(player) ~= 0
end

function grouper.role(player: Player): string
	return ROLE_CACHE[GET_RANK(player) + 1]
end

function grouper.players(): { [Player]: number }
	return table.clone(PLAYER_RANKS)
end

-- using role_hash_cache because
function grouper.roles(): { [number]: string }
	return table.clone(ROLE_HASH_CACHE)
end

table.freeze(grouper.init)
return table.freeze(grouper)


--[[
	leaderstats
	roblox leaderstats wrapper providing a nicer api, compared to dealing with instances directly
]]

local RunService = game:GetService("RunService")
local Players = game:GetService("Players")

type AllowedValues = number | string

type AllowedDefaults = "float" | "string" | "integer" | AllowedValues

type Stats = { [string]: AllowedValues }

type LeaderstatsFolder =  Folder & { [string]: StringValue | NumberValue }

-- this exists for no reason other than "well theres an integer pattern for having an integer with a number literal so why not for floats"
type FloatPattern = "^f(%-?%d+%.?%d*)$"

type IntegerPattern = "^i(%-?%d+)$"

type function LeaderstatsCreator(stats: type, player_type: type)
	if stats.tag ~= "table" then
		error("stats is not of type 'table'")
	end

	local integer_pattern = IntegerPattern:value()
	local float_pattern = FloatPattern:value()
	local new_partial_stats = types.newtable()
	local new_stats = types.newtable()

	for key, value in stats:properties() do
		if key:is("singleton") then
			local raw_key = key:value()

			if type(raw_key) ~= "string" then
				error(`key '{key}' does not have subtype 'string'`)
			end
		else
			error(`key '{key}' is not a type singleton`)
		end

		value = value.read or value.write
		local prop_value = value

		if value.tag == "singleton" then
			local raw_value = value:value()

			if
				raw_value == "float" or
				raw_value == "integer" or
				type(raw_value) == "number" or
				string.match(raw_value, float_pattern) or
				string.match(raw_value, integer_pattern)
			then
				prop_value = types.number
			elseif raw_value == "string" or type(raw_value) == "string" then
				prop_value = types.string
			else
				error(`value '{value}' for key '{key}' is not of type '"integer" | "string" | "float" | number | string'`)
			end
		elseif
			-- using ~= for all these checks didn't work :(
			not (value == types.number or
			value == types.string or
			value.tag == "union")
		then
			error(`value '{value}' for key '{key}' is not of type 'string | number' or is a type union made up of 'string | number'`)
		end

		new_partial_stats:setproperty(key, types.optional(prop_value))
		new_stats:setproperty(key, prop_value)
	end

	return types.newtable(nil, {
		index = player_type, readresult = new_stats,
		writeresult = new_partial_stats
	})
end

export type Leaderstats<S = {}> = LeaderstatsCreator<S, Player>

local PLAYER_TO_FOLDER = {} :: { [Player]: LeaderstatsFolder? }
local STATS_TO_FOLDER = {} :: { [Stats]: LeaderstatsFolder }
local LEADERSTATS_FOLDER_TEMPLATE = Instance.new("Folder")
local INTEGER_PATTERN: IntegerPattern = "^i(%-?%d+)$"
local FLOAT_PATTERN: FloatPattern = "^f(%-?%d+%.?%d*)$"
local PLAYER_TO_STATS = {} :: { [Player]: Stats }
local LEADERSTATS_FOLDER_NAME = "leaderstats"
local LEADERSTATS_OBJECT: { [Player]: Stats }
local STAT_NAMES = {} :: { string }
local FULL_LINES: { string }
local LINES: { string }

local leaderstats = {}
local stats = {}

local function STATS_TO_STRING(stats: Stats, indent: string, outer_indent: string): string
	local folder = STATS_TO_FOLDER[stats]

	for index, stat_name in STAT_NAMES do
		local stat_instance = folder[stat_name]

		LINES[index] = if stat_instance:IsA("StringValue") then
				`{stat_name} = '{stat_instance.Value}'`
			else
				`{stat_name} = {stat_instance.Value}`
	end

	local final = string.format(
		"{\n%*%*,\n%*}", indent,
		table.concat(LINES, string.format(",\n%*", indent)),
		outer_indent
	)
	table.clear(LINES)
	return final
end

local function GET_OR_CREATE_STATS(player: Player, folder: LeaderstatsFolder): Stats
	local player_stats = PLAYER_TO_STATS[player]

	if player_stats then
		return player_stats :: any
	else
		local new_player_stats = table.freeze(setmetatable({}, stats)) :: any
		PLAYER_TO_STATS[player] = new_player_stats
		STATS_TO_FOLDER[new_player_stats] = folder
		return new_player_stats
	end
end

local function GET_OR_CREATE_FOLDER(player: Player): LeaderstatsFolder
	local folder = PLAYER_TO_FOLDER[player]

	if folder then
		return folder :: any
	else
		local folder = player:FindFirstChild(LEADERSTATS_FOLDER_NAME) :: any

		if folder then
			PLAYER_TO_FOLDER[player] = folder
			return folder
		else
			local new_folder = LEADERSTATS_FOLDER_TEMPLATE:Clone()
			PLAYER_TO_FOLDER[player] = new_folder
			new_folder.Parent = player
			return new_folder
		end
	end
end

function stats.__newindex(stats: Stats, stat: string, value: AllowedValues): AllowedValues
	STATS_TO_FOLDER[stats][stat].Value = value
	return value
end

function stats.__index(stats: Stats, stat: string): AllowedValues
	return STATS_TO_FOLDER[stats][stat].Value
end

function stats.__tostring(stats: Stats): string
	return STATS_TO_STRING(stats, "\t", "")
end

function leaderstats.__newindex(leaderstats: Leaderstats, player: Player, partial_stats: { [string]: AllowedValues? }?): Stats
	if player and player.Parent == Players then
		local folder = GET_OR_CREATE_FOLDER(player)

		if partial_stats then
			for _, stat in STAT_NAMES do
				local new_value = partial_stats[stat]

				if new_value then
					folder[stat].Value = new_value
				end
			end
		end
		return GET_OR_CREATE_STATS(player, folder)
	else
		error(`[LEADERSTATS]: cannot set key '{player}', as key is not of type 'Player'`)
	end
end

function leaderstats.__index(leaderstats: Leaderstats, player: Player): Stats
	if player and player.Parent == Players then
		return GET_OR_CREATE_STATS(player, GET_OR_CREATE_FOLDER(player))
	else
		error(`[LEADERSTATS]: cannot set key '{player}', as key is not of type 'Player'`)
	end
end

function leaderstats.__tostring(leaderstats: Leaderstats): string
	local index = 1

	for player, stats in leaderstats do
		FULL_LINES[index] = string.format(
			"\t[Player<\"%*\", %*>] = %*", player.DisplayName, player.UserId,
			STATS_TO_STRING(stats :: any, "\t\t", "\t")
		)
		index += 1
	end

	local final = `\{\n{table.concat(FULL_LINES, ",\n\t")}\n\}`
	table.clear(FULL_LINES)
	return final
end

--[[
	Creates a new leaderstats object
]]
local function create<S>(stats: S): Leaderstats<S>
	if LEADERSTATS_OBJECT then
		error("[LEADERSTATS]: cannot have more than 1 leaderstats object")
	end

	LEADERSTATS_OBJECT = setmetatable({}, leaderstats)
	local stat_amount = 0

	for name, value in stats do
		local integer_match = tonumber(string.match(value, INTEGER_PATTERN))
		local float_match = not integer_match and tonumber(string.match(value, FLOAT_PATTERN))
		local stat_instance: ValueBase

		if value == "string" then
			stat_instance = Instance.new("StringValue")
		elseif value == "float" then
			stat_instance = Instance.new("NumberValue")
		elseif value == "integer" then
			stat_instance = Instance.new("IntValue")
		elseif type(value) == "number" then
			stat_instance = Instance.new("NumberValue")
			stat_instance.Value = value
		elseif integer_match then
			stat_instance = Instance.new("IntValue")
			stat_instance.Value = integer_match
		elseif float_match then
			stat_instance = Instance.new("NumberValue")
			stat_instance.Value = float_match
		else
			stat_instance =  Instance.new("StringValue")
			stat_instance.Value = value
		end

		stat_instance.Parent = LEADERSTATS_FOLDER_TEMPLATE
		table.insert(STAT_NAMES, name)
		stat_instance.Name = name
		stat_amount += 1
	end

	FULL_LINES = table.create(Players.MaxPlayers) :: any
	LINES = table.create(stat_amount) :: any
	return LEADERSTATS_OBJECT :: any
end

do

	--[[
		this is to fix the edge case where the folder is created on the client,
		when its going to be created on the server but like just hasn't yet
	--]]
	if RunService:IsClient() then
		Players.DescendantAdded:Connect(function(instance)
			local parent = instance.Parent :: any

			if parent and instance.Name == LEADERSTATS_FOLDER_NAME then
				local folder = PLAYER_TO_FOLDER[parent]

				if folder then
					folder:Destroy()
				end
				PLAYER_TO_FOLDER[parent] = instance
			end
		end)
	end

	Players.PlayerRemoving:Connect(function(player)
		local stats = PLAYER_TO_STATS[player]

		if stats then
			PLAYER_TO_FOLDER[player] = nil
			PLAYER_TO_STATS[player] = nil
			STATS_TO_FOLDER[stats] = nil
		end
	end)

	Players.PlayerAdded:Connect(function(player)
		if LEADERSTATS_OBJECT then
			GET_OR_CREATE_STATS(
				LEADERSTATS_OBJECT, player, GET_OR_CREATE_FOLDER(player)
			)
		end
	end)

	LEADERSTATS_FOLDER_TEMPLATE.Name = LEADERSTATS_FOLDER_NAME
	table.freeze(leaderstats)
	table.freeze(stats)

end

return create


local testkit = require("@testkit")
local leventine = require("./init")

local TEST, CASE, CHECK, FINISH = testkit.test()

TEST("leventine", function()
	do CASE("score_32")
		CHECK(
			leventine.score("hi hello im meowing so much rn", "and IM A SERTIFIED MEOW HATER :(") == 27
		)
	end

	do CASE("score_32_sensitive")
		CHECK(
			leventine.score("hi hello im meowing so much rn", "and IM A SERTIFIED MEOW HATER :(", true) == 30
		)
	end

	do CASE("score_x")

	end

	do CASE("score_x_sensitive")

	end

	do CASE("map")

	end

	do CASE("map_min_score")

	end

	do CASE("map_case_sensitive")

	end

	do CASE("closest")

	end

	do CASE("closest_case_sensitive")

	end

	do CASE("has_match_false")

	end

	do CASE("has_match_sensitive_false")

	end

	do CASE("has_match_true")

	end

	do CASE("has_match_sensitive_true")

	end
end)


local testkit = require("@testkit")
local linkedlist = require("./init")

local TEST, CASE, CHECK, FINISH = testkit.test()

TEST("linked list", function()

end)


--[[
	loganalytics
	a simple wrapper around AnalyticsService's bad api, that makes it into a decent api
]]

local AnalyticsService = game:GetService("AnalyticsService")

export type CustomFields = { [string]: string }

type ConvertedCustomFields = { [Enum.AnalyticsCustomFieldKeys]: string }?

type BaseAnalyticsInfo = {
	custom_fields: CustomFields?
}

export type AnalyticsCustomEventInfo = BaseAnalyticsInfo & {
	value: number?,
	name: string,
}

export type AnalyticsOnboardingFunnelStepInfo = BaseAnalyticsInfo & {
	step_name: string?,
	step: number,
}

export type AnalyticsProgressionInfo = BaseAnalyticsInfo & {
	status: Enum.AnalyticsProgressionType,
	level_name: string,
	path_name: string,
	level: number,
	step: number?,
}

export type AnalyticsEconomyInfo = BaseAnalyticsInfo & {
	transaction_type: Enum.AnalyticsEconomyTransactionType,
	flow_type: Enum.AnalyticsEconomyFlowType,
	ending_balance: number,
	currency_type: string,
	amount: number,
	item_sku: string?,
}

export type AnalyticsFunnelStepInfo = BaseAnalyticsInfo & {
	funnel_name: string,
	sessionid: string?,
	step_name: string?,
	step: number?,
}

type CustomLoggerMethodInfo<F = CustomFields> = {
	value: number?,
	fields: F,
}

type LoggerPrototype = {
	event: (<S, N, F>(self: S & Logger, name: N & string, fields: F & {}) -> S & { [N]: (self: Logger, player: Player, info: CustomLoggerMethodInfo<F>) -> () }) &
		(<S, N>(self: S & Logger, name: N & string) -> S & { [N]: (self: Logger, player: Player, value: number?) -> () }),
	onboarding_funnel_step: (self: Logger, player: Player, info: AnalyticsOnboardingFunnelStepInfo) -> (),
	progression: (self: Logger, player: Player, info: AnalyticsProgressionInfo) -> (),
	funnel_step: (self: Logger, player: Player, info: AnalyticsFunnelStepInfo) -> (),
	economy: (self: Logger, player: Player, info: AnalyticsEconomyInfo) -> (),
	__index: LoggerPrototype,
}

export type Logger = typeof(setmetatable({} :: {}, {} :: LoggerPrototype))

local CUSTOM_FIELD_FORMAT = "%* = %*"
local FORMAT = string.format
local CUSTOM_FIELDS = {
	Enum.AnalyticsCustomFieldKeys.CustomField01,
	Enum.AnalyticsCustomFieldKeys.CustomField02,
	Enum.AnalyticsCustomFieldKeys.CustomField03,
}
local MAX_CUSTOM_FIELDS = #CUSTOM_FIELDS
local TOO_MANY_ENTRIES_ERR = `[LOG_ANALYTICS] cannot have more than {MAX_CUSTOM_FIELDS} custom fields`

@native
local function TABLE_TO_CUSTOM_FIELDS(fields: CustomFields): ConvertedCustomFields
	local return_tbl = {}
	local index = 1

	for key, value in fields do
		if index == MAX_CUSTOM_FIELDS then
			error(TOO_MANY_ENTRIES_ERR)
		else
			return_tbl[CUSTOM_FIELDS[index]] = FORMAT(CUSTOM_FIELD_FORMAT, key, value)
			index += 1
		end
	end
	return return_tbl
end

local function INFO_TO_CUSTOM_FIELDS(info: BaseAnalyticsInfo): ConvertedCustomFields?
	local custom_fields = info.custom_fields

	if custom_fields then
		return TABLE_TO_CUSTOM_FIELDS(custom_fields)
	else
		return nil
	end
end

local function onboarding_funnel_step(player: Player, info: AnalyticsOnboardingFunnelStepInfo)
	AnalyticsService:LogOnboardingFunnelStepEvent(
		player, info.step, info.step_name, INFO_TO_CUSTOM_FIELDS(info)
	)
end

local function progression(player: Player, info: AnalyticsProgressionInfo)
	AnalyticsService:LogProgressionEvent(
		player, info.path_name, info.status, info.level,
		info.level_name, INFO_TO_CUSTOM_FIELDS(info)
	)
end

local function funnel_step(player: Player, info: AnalyticsFunnelStepInfo)
	AnalyticsService:LogFunnelStepEvent(
		player, info.funnel_name, info.sessionid, info.step,
		info.step_name, INFO_TO_CUSTOM_FIELDS(info)
	)
end

local function custom(player: Player, info: AnalyticsCustomEventInfo)
	AnalyticsService:LogCustomEvent(
		player, info.name, info.value, INFO_TO_CUSTOM_FIELDS(info)
	)
end

local function economy(player: Player, info: AnalyticsEconomyInfo)
	AnalyticsService:LogEconomyEvent(
		player, info.flow_type, info.currency_type, info.amount, info.ending_balance,
		info.transaction_type :: any, info.item_sku, INFO_TO_CUSTOM_FIELDS(info)
	)
end

local logger = {} :: LoggerPrototype
logger.__index = logger

logger.event = function(logger: any, name: string, fields: CustomFields?)
	if fields then
		logger[name] = function(player: Player, info: CustomLoggerMethodInfo)
			AnalyticsService:LogCustomEvent(player, name, info.value, TABLE_TO_CUSTOM_FIELDS(info.fields))
		end
	else
		logger[name] = function(player: Player, value: number?)
			AnalyticsService:LogCustomEvent(player, name, value)
		end
	end

	return logger :: any
end :: any

-- having parens around all of these local function calls so they inline
-- fuck you multiret
function logger.onboarding_funnel_step(logger, player, info)
	return ( onboarding_funnel_step(player, info) )
end

function logger.progression(logger, player, info)
	return ( progression(player, info) )
end

function logger.funnel_step(logger, player, info)
	return ( funnel_step(player, info) )
end

function logger.economy(logger, player, info)
	return ( economy(player, info) )
end

local loganalytics_mt = {}

function loganalytics_mt.__call(): Logger
	return setmetatable({}, logger)
end

table.freeze(loganalytics_mt)
table.freeze(logger)

return table.freeze(setmetatable({
	onboarding_funnel_step = onboarding_funnel_step,
	funnel_step = funnel_step,
	progression = progression,
	economy = economy,
	custom = custom,
}, loganalytics_mt))


--[[
	pagesutil
	utility for dealing with roblox page instances easily
]]

export type PageInfo = {
	item_index: number,
	page_len: number,
	page: number,
}

local ADVANCE_ERR_FORMAT = "[PAGES]: an error occurred while trying to advance to the next page\n\tadvance-next-page-err: %s"

local pagesutil = {}

function pagesutil.iter(
	pages: Pages,
	f: (info: PageInfo, item: { [any]: any }) -> ("continue" | "break")?
)
	local advance_to_next_page = pages.AdvanceToNextPageAsync
	local page = pages:GetCurrentPage()
	local page_info = {
		page_len = #page,
		item_index = 0,
		page = 1,
	}

	for item_index, item in page do
		page_info.item_index += 1
		local result = f(page_info, item)

		if result == "break" then
			return
		end
	end

	if not pages.IsFinished then
		repeat
			-- ive had issues with MemoryStoreHashMapPages erroring occasionally with this,
			-- because it didnt correctly set IsFinished to true when the hashmap had litterally 0 entries in it
			-- so its like litterally impossible for there to be another page
			local success, err = pcall(advance_to_next_page, pages)

			if success then
				local page = pages:GetCurrentPage()
				page_info.page_len = #page
				page_info.item_index = 0
				page_info.page += 1

				for item_index, item in page do
					page_info.item_index += 1
					local result = f(page_info, item)

					if result == "break" then
						return
					end
				end
			else
				warn(string.format(ADVANCE_ERR_FORMAT, err))
				return
			end
		until pages.IsFinished
	end
end

function pagesutil.toarray(pages: Pages): { { [any]: any } }
	local advance_to_next_page = pages.AdvanceToNextPageAsync
	local contents = pages:GetCurrentPage()

	if not pages.IsFinished then
		repeat
			local success, err = pcall(advance_to_next_page, pages)

			if success then
				local current_page = pages:GetCurrentPage()
				table.move(current_page, 1, #current_page, #contents + 1, contents)
			else
				warn(string.format(ADVANCE_ERR_FORMAT, err))
				break
			end
		until pages.IsFinished
	end
	return contents
end

return table.freeze(pagesutil)


--[[
	player loaded
	handles callbacks for a player loaded remote, because having a player loaded remote
	is a good idea so you don't fill up the remote queue for a player
	before they've been able to receive remote events
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local character = require("@libs/character")

export type PlayerLoadedCallback = (player: Player & { Character: character.Character }) -> ()

local CALLBACKS_WAITING_FOR_NEXT_PLAYER = {} :: { PlayerLoadedCallback }
local PLAYERS_LOADED = table.create(Players.MaxPlayers) :: { Player }
local THREADS_WAITING_FOR_PLAYERS = {} :: { [Player]: { thread } }
local THREADS_WAITING_FOR_NEXT_PLAYER = {} :: { thread }
local CALLBACKS = {} :: { PlayerLoadedCallback }
local REMOTE_NAME = "PlayerLoaded"
local LOADED_COUNT = 0

local function SWAP_REMOVE(t: { unknown }, v: unknown)
	local index = table.find(t, v)

	if index then
		local length = #t

		if index ~= length then
			t[index] = t[length]
			t[length] = nil
		else
			t[length] = nil
		end
	end
end

local function CREATE_DISCONNECT<V>(t:{ V }, v: V): () -> ()
	return function()
		SWAP_REMOVE(t, v)
	end
end

local player_loaded = {}

--[[
	Calls the provided callback everytime a player loads in,
	unless disconnected using the returned disconnect function.
]]
function player_loaded.on(f: PlayerLoadedCallback, dont_run_for_existing: true?): () -> ()
	if not dont_run_for_existing then
		for _, player in PLAYERS_LOADED do
			task.defer(f, player :: any)
		end
	end

	table.insert(CALLBACKS, f)
	return CREATE_DISCONNECT(CALLBACKS, f)
end

--[[
	Yields the current thread until the provided player loads in,
	or if the player leaves the game it'll then resume the current thread
	with nil instead of the player.
]]
function player_loaded.wait_for_player(player: Player): Player?
	if not table.find(PLAYERS_LOADED, player) then
		local threads = THREADS_WAITING_FOR_PLAYERS[player]

		if threads then
			table.insert(threads, coroutine.running())
		else
			THREADS_WAITING_FOR_PLAYERS[player] = { coroutine.running() }
		end
		return coroutine.yield()
	else
		return player
	end
end

--[[
	Calls the provided callback with the next player that loads in,
	unless disconnected using the returned disconnect function.
]]
function player_loaded.once(f: PlayerLoadedCallback): () -> ()
	table.insert(CALLBACKS_WAITING_FOR_NEXT_PLAYER, f)
	return CREATE_DISCONNECT(CALLBACKS_WAITING_FOR_NEXT_PLAYER, f)
end

--[[
	Yields the current thread, and resumes it with the next player that loads in
]]
function player_loaded.wait(): Player
	table.insert(THREADS_WAITING_FOR_NEXT_PLAYER, coroutine.running())
	return coroutine.yield()
end

do

	local function run_for_player(player: Player)
		local threads = THREADS_WAITING_FOR_PLAYERS[player]

		LOADED_COUNT += 1
		PLAYERS_LOADED[LOADED_COUNT] = player

		if threads then
			THREADS_WAITING_FOR_PLAYERS[player] = nil

			for _, thread in threads do
				task.defer(thread, player)
			end
		end

		for _, callback in CALLBACKS_WAITING_FOR_NEXT_PLAYER do
			task.defer(callback, player :: any)
		end

		for _, thread in THREADS_WAITING_FOR_NEXT_PLAYER do
			task.defer(thread, player)
		end

		for _, callback in CALLBACKS do
			task.defer(callback, player :: any)
		end

		table.clear(CALLBACKS_WAITING_FOR_NEXT_PLAYER)
		table.clear(THREADS_WAITING_FOR_NEXT_PLAYER)
	end

	if RunService:IsServer() then
		local remote = Instance.new("RemoteEvent")
		remote.Parent = ReplicatedStorage
		remote.Name = REMOTE_NAME

		remote.OnServerEvent:Connect(function(loaded_player)
			if not table.find(PLAYERS_LOADED, loaded_player) then
				remote:FireClient(loaded_player, PLAYERS_LOADED)
				run_for_player(loaded_player)

				for _, player in PLAYERS_LOADED do
					remote:FireClient(player, loaded_player)
				end
			end
		end)
	else
		local remote: RemoteEvent = ReplicatedStorage:WaitForChild(REMOTE_NAME) :: any

		remote.OnClientEvent:Connect(function(player_or_players: Player | { Player })
			if type(player_or_players) == "table" then
				for _, player in player_or_players do
					if not table.find(PLAYERS_LOADED, player) then
						run_for_player(player)
					end
				end
			else
				run_for_player(player_or_players)
			end
		end)

		task.defer(function()
			if not game:IsLoaded() then
				game.Loaded:Wait()
			end

			remote:FireServer()
			run_for_player(Players.LocalPlayer)
		end)
	end

	Players.PlayerRemoving:Connect(function(player)
		local threads = THREADS_WAITING_FOR_PLAYERS[player]

		if threads then
			THREADS_WAITING_FOR_PLAYERS[player] = nil

			for _, thread in threads do
				task.defer(thread, nil)
			end
		end

		SWAP_REMOVE(PLAYERS_LOADED, player)
	end)

end

return table.freeze(player_loaded)


--!native

--[[
	playerzone
	simple util module for detecting players in rectangular zones
]]

local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local character = require("@libs/character")

export type ZoneType = "WHITELIST" | "BLACKLIST"

type BaseZone = {
	callback: (character: character.Character, player: Player) -> (),
	blacklist: { PlayerZoneInfo },
	size_halved_x: number,
	size_halved_y: number,
	size_halved_z: number,
	cframe: CFrame,
	size: Vector3,
	id: number,
}

type PlayerZoneInfo = {
	character: character.Character,
	rootpart: BasePart,
	position: Vector3,
	player: Player,
}

export type WhitelistZone = BaseZone & {
	whitelisted: { Player },
	type: "WHITELIST",
	blacklisted: nil,
}

export type BlacklistZone = BaseZone & {
	blacklisted: { Player },
	type: "BLACKLIST",
	whitelisted: nil,
}

export type Zone = WhitelistZone | BlacklistZone

type BaseZoneInfo = {
	callback: (character: character.Character, player: Player) -> (),
	cframe: CFrame,
	size: Vector3,
}

export type ZoneInfo<Type = ZoneType> = BaseZoneInfo & {
	type: Type?,
}

local POINT_TO_OBJECT_SPACE = CFrame.identity.PointToObjectSpace
local PLAYER_ZONE_INFO = {} :: { PlayerZoneInfo }
local WHITELIST_ZONES = {} :: { WhitelistZone }
local BLACKLIST_ZONES = {} :: { BlacklistZone }
local ZONES = {} :: { Zone }
local NEXT_ID = 1

local function FIND_AND_REMOVE_ZONE_INFO(tbl: { PlayerZoneInfo }, player: Player)
	for index, player_zone_info in tbl do
		if player_zone_info.player == player then
			table.remove(tbl, index)
		end
	end
end

local function FIND_AND_REMOVE<V>(tbl: { V }, v: V)
	local index = table.find(tbl, v)

	if index then
		table.remove(tbl, index)
	end
end

local function remove(zone: Zone, player: Player)
	FIND_AND_REMOVE_ZONE_INFO(zone.blacklist, player)
	FIND_AND_REMOVE(
		if zone.type == "WHITELIST" then zone.whitelisted else zone.blacklisted,
		player
	)
end

local function insert(zone: Zone, player: Player)
	local whitelisted = zone.whitelisted :: any
	local blacklisted = zone.blacklisted :: any

	if zone.type == "WHITELIST" and not table.find(whitelisted, player) then
		table.insert(whitelisted, player)
		FIND_AND_REMOVE_ZONE_INFO(zone.blacklist, player)
	elseif not table.find(blacklisted, player) then
		local character = player.Character

		table.insert(blacklisted, player)

		if character then
			for _, player_zone_info in PLAYER_ZONE_INFO do
				if player_zone_info.player == player then
					local blacklist = zone.blacklist

					if not table.find(blacklist, player_zone_info) then
						table.insert(blacklist, player_zone_info)
					end
					return
				end
			end
		end
	end
end

local function create(info: ZoneInfo): Zone
	local size = info.size - Vector3.one
	local size_halved = size / 2
	local zone = {
		type = info.type or "BLACKLIST" :: any,
		size_halved_x = size_halved.X,
		size_halved_y = size_halved.Y,
		size_halved_z = size_halved.Z,
		callback = info.callback,
		cframe = info.cframe,
		blacklist = {},
		id = NEXT_ID,
		size = size,
	}

	if info.type == "WHITELIST" then
		zone.whitelisted = {}
		table.insert(WHITELIST_ZONES, zone)
	else
		zone.blacklisted = {} :: any
		table.insert(BLACKLIST_ZONES, zone :: any)
	end

	table.insert(ZONES, zone)
	NEXT_ID += 1
	return zone :: any
end

local function destroy(zone: Zone)
	if zone.type == "WHITELIST" then
		FIND_AND_REMOVE(WHITELIST_ZONES, zone)
	else
		FIND_AND_REMOVE(BLACKLIST_ZONES, zone)
	end

	FIND_AND_REMOVE(ZONES, zone)
end

do

	RunService.PostSimulation:Connect(function()
		for _, player_zone_info in PLAYER_ZONE_INFO do
			player_zone_info.position = player_zone_info.rootpart.CFrame.Position
		end

		for key, zone in ZONES do
			local size_halved_x = zone.size_halved_x
			local size_halved_y = zone.size_halved_y
			local size_halved_z = zone.size_halved_z
			local callback = zone.callback
			local cframe = zone.cframe

			for _, player_zone_info in zone.blacklist :: { PlayerZoneInfo } do
				local offset = (POINT_TO_OBJECT_SPACE(cframe, player_zone_info.position))
				local is_in_box = math.abs(offset.X) <= size_halved_x
					and math.abs(offset.Y) <= size_halved_y
					and math.abs(offset.Z) <= size_halved_z

				if is_in_box then
					callback(player_zone_info.character, player_zone_info.player)
				end
			end
		end
	end)

	Players.PlayerAdded:Connect(function(player)
		character.removing(player, function(character)
			FIND_AND_REMOVE_ZONE_INFO(PLAYER_ZONE_INFO, player)

			for _, zone in ZONES do
				FIND_AND_REMOVE_ZONE_INFO(zone.blacklist, player)
			end
		end)
		character.added(player, function(character)
			local rootpart: BasePart = character.PrimaryPart :: any
			local player_zone_info = {
				position = rootpart.CFrame.Position,
				character = character,
				rootpart = rootpart,
				player = player,
			}

			table.insert(PLAYER_ZONE_INFO, player_zone_info)

			for _, zone in WHITELIST_ZONES do
				if not table.find(zone.whitelisted, player) then
					table.insert(zone.blacklist, player_zone_info)
				end
			end

			for _, zone in BLACKLIST_ZONES do
				if table.find(zone.blacklisted, player) then
					table.insert(zone.blacklist, player_zone_info)
				end
			end
		end)

		Players.PlayerRemoving:Connect(function(player)
			for _, zone in WHITELIST_ZONES do
				FIND_AND_REMOVE(zone.whitelisted, player)
			end

			for _, zone in BLACKLIST_ZONES do
				FIND_AND_REMOVE(zone.blacklisted, player)
			end
		end)
	end)
end

return table.freeze({
	create = (create :: any) :: ((info: BaseZoneInfo & { type: "BLACKLIST"? }) -> BlacklistZone) &
		((info: BaseZoneInfo & { type: "WHITELIST" }) -> WhitelistZone),
	destroy = destroy,
	remove = remove,
	insert = insert,
})


local testkit = require("@testkit")
-- local playerzone = require("init")

local TEST, CASE, CHECK, FINISH = testkit.test()

local NOOP = function() end

TEST("player zone", function()
	-- cannot actually run this test rn, because of the game global not being set
	-- because it cant be set because of a lune bug :(
	if true then
		return
	end

	do CASE("create")
		local zone = playerzone.create({
			cframe = CFrame.identity,
			size = Vector3.new(4, 5, 4),
			callback = NOOP,
			type = nil,
		})
	end

	do CASE("insert")

	end

	do CASE("remove")

	end

	do CASE("detect")

	end
end)


--[[
	plugtilly
	roblox plugin utility
]]

export type DockWidgetPluginGuiInfo = {
	initally_enabled_should_override_restore: boolean,
	inital_dock_state: Enum.InitialDockState,
	initally_enabled: boolean,
	inital_size: vector,
	min_size: vector?,
	title: string?,
}

export type Setting<V> = (value: V?) -> V

local SETTING_NIL = "__PLUGTILLY_SETTING_NIL__"

local function SET_SETTING(plugin: Plugin, key: string, value: unknown)
	plugin:SetSetting(key, if value ~= nil then value else SETTING_NIL)
end

local function GET_SETTING(plugin: Plugin, key: string): any
	local value = plugin:GetSetting(key)
	return if value ~= SETTING_NIL then value else nil
end

--[[
	Gives the actual name of the plugin, as plugins that are loaded in via
	the plugins folder have "user_" appended to the beginning and ".rbxm"
 	appended at the end of their instance name.
]]
local function GET_NAME(plugin: Plugin): string
	local name = plugin.Name
	return string.match(name, "user_(.-)%.rbxm") or name
end

local plugtilly = {
	name = GET_NAME,
}

--[[
	Wrapper for Plugin:CreateDockWidgetPluginGui() that instead uses a table for info,
	and also will automatically set the name of the DockWidgetPluginGui based on the plugins name
	and the title if provided in the info table.
	It also sets the DockWidgetPluginGui to have the ZIndexBehavior of Sibling, and to not ResetOnSpawn.
]]
function plugtilly.create_dock_widget_plugin_gui(
	plugin: Plugin, id: string, info: DockWidgetPluginGuiInfo
): DockWidgetPluginGui
	local plugin_name = GET_NAME(plugin)
	local inital_size = info.inital_size
	local min_size = info.min_size or inital_size
	local title = info.title

	local gui = plugin:CreateDockWidgetPluginGui(
		id, DockWidgetPluginGuiInfo.new(
			info.inital_dock_state, info.initally_enabled,
			info.initally_enabled_should_override_restore,
			inital_size.x, inital_size.y,
			min_size.x, min_size.y
		)
	)

	if title then
		gui.Name = `{plugin_name} "{title}"`
		gui.Title = title
	else
		gui.Title = plugin_name
		gui.Name = plugin_name
	end

	gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	gui.ResetOnSpawn = false
	return gui
end

--[[
	Simple function for migrating a old setting to a new setting
]]
function plugtilly.migrate_setting<V>(
	plugin: Plugin, old_key: string, new_setting: Setting<V>,
	converter: (value: any) -> V
)
	new_setting(converter(GET_SETTING(plugin, old_key)))
end

--[[
	Wrapper around Plugin:GetSetting() and Plugin:SetSetting(),
	that gives single function that when called it'll give you the current setting value,
	and calling it with an argument will also set the setting.
]]
function plugtilly.setting<V>(plugin: Plugin, key: string, default: V): Setting<V>
	if plugin:GetSetting(key) == nil then
		SET_SETTING(plugin, key, default)
	end

	return function(...)
		if select("#", ...) ~= 0 then
			local value = select(1, ...)
			SET_SETTING(plugin, key, value)
			return value
		end

		return GET_SETTING(plugin, key)
	end
end

return table.freeze(plugtilly)


--[[
	race
	a function for getting the first return out of a vararg of functions
]]

local cross = require("@libs/cross")

local function callback_handler<A..., R...>(
	f: (A...) -> R..., threads: { thread },
	main_thread: thread, ...: A...
)
	local callback_thread = coroutine.running()
	local results = { f(...) }

	for _, thread in threads do
		if thread ~= callback_thread then
			local success = coroutine.close(thread)

			if not success then
				warn(debug.traceback(thread, "[RACE]: an error occured whilst trying to close thread"))
			end
		end
	end

	cross.spawn(main_thread, table.unpack(results))
end

local function race<A..., R...>(callbacks: { (A...) -> R... }, ...: A...): R...
	local threads = table.create(#callbacks) :: { thread }
	local main_thread = coroutine.running()

	for index, f in callbacks do
		threads[index] = cross.spawn(callback_handler, f, threads, main_thread, ...)
	end

	return coroutine.yield()
end

return race


local testkit = require("@testkit")
local task = require("@lune/task")
local race = require("./init")

local TEST, CASE, CHECK, FINISH = testkit.test()

TEST("RACE", function()
	CASE("MAIN")

	local result = race({
		function(str: string)
			task.wait(5)
			return `{str}! i never return ;c`
		end,
		function(str: string)
			task.wait()
			return `{str}! i return!`
		end
	}, "yay")

	CHECK(result == "yay! i return!")
end)

assert(FINISH())
print()


--!native

--[[
	random
	pure luau functional equivalent to Roblox's random class
]]

type SetState<V> = (v: V) -> ()

type MinMaxMethod<S, MIN, MAX, R> = (set_state: SetState<S>, min: MIN, max: MAX, seed: S) -> R

type Method<S, V, R...> = (set_state: SetState<S>, v: V, seed: S) -> R...

--[[
	Generates pseudorandom numbers, vectors, and directions.
]]
type RandomSpec<TS = nil, TV = nil, NV = nil, V = nil, F = nil, I = nil, MI = nil> = {
	--[[
		Returns a normalized vector (unit vector) with a pseudorandom direction.
	]]
	normalized_vector: NV,
	--[[
		Uniformly shuffles an array-like table in-place.
	]]
	shuffle: TS,
	--[[
		Returns a pseudorandom integer uniformly distributed over `[min, max]`.
	]]
	integer: I,
	--[[
		Returns a pseudorandom vector uniformly distributed over `[min, max]`.
	]]
	vector: V,
	--[[
		Returns a random value from an array-like table.
	]]
	value: TV,
	--[[
		Returns a pseudorandom number uniformly distributed over `[min, max]`.
	]]
	float: F,
	__index: MI, -- if this isn't here the solver has a seizure
}

type RandomPrototype = RandomSpec<
	<V>(random: Random, t: { V }) -> (),
	<V>(random: Random, t: { V }) -> V,
	(random: Random) -> vector,
	(random: Random, min: vector?, max: vector?) -> vector,
	(random: Random, min: number?, max: number?) -> number,
	(random: Random, min: number?, max: number?) -> number,
	RandomPrototype
>

export type Random = setmetatable<{
	state: number,
}, RandomPrototype>

type Export = RandomSpec<
	<V>(t: { V }, seed: number?) -> (),
	<V>(t: { V }, seed: number?) -> V,
	(seed: number?) -> vector,
	(min: vector?, max: vector?, seed: number?) -> vector,
	(min: number?, max: number?, seed: number?) -> number,
	(min: number?, max: number?, seed: number?) -> number
> & {
	--[[
		Creates a new Random object, useful for not having to constantly pass around a seed variable.
	]]
	create: (seed: number?) -> Random,
	--[[
		Returns the current seed being used for non-class random functions.
	]]
	get_seed: () -> number,
}

local function CREATE_SEED(): number
	return os.time() * os.clock() % 100
end

local DEFAULT_MAX = 2 ^ 23
local DEFAULT_MIN = -DEFAULT_MAX
local DEFAULT_MAX_VECTOR = vector.create(DEFAULT_MAX, DEFAULT_MAX, DEFAULT_MAX)
local DEFAULT_MIN_VECTOR = -DEFAULT_MAX_VECTOR
local SEED = CREATE_SEED()

local function is_close(a: number, b: number): boolean
	return math.abs(a - b) <= math.max(
		1e-09 * math.max(math.abs(a), math.abs(b)), 0
	)
end

local function RANDOMIZE_STATE(state: number): number
	return (state * 6364136223846793) + 105
end

local function SET_GSTATE(seed: number)
	if seed == SEED or is_close(seed, SEED) then
		seed = RANDOMIZE_STATE(seed)
	end

	SEED = seed
	math.randomseed(seed)
end

local function SET_STATE(random: Random)
	local state = RANDOMIZE_STATE(random.state)
	math.randomseed(state)
	random.state = state
end

local function CREATE_CLASS_METHOD<V, R...>(f: Method<Random, V, R...>): (Random, V) -> R...
	return function(random, v)
		return f(SET_STATE, v, random)
	end
end

local function CREATE_METHOD<V, R...>(f: Method<number, V, R...>): (V, number?) -> R...
	return function(v, seed)
		return f(SET_GSTATE, v, seed or SEED)
	end
end

local function CREATE_CLASS_METHOD_RANGE<MIN, MAX, R>(
	f: MinMaxMethod<Random, MIN, MAX, R>
): (Random, MIN, MAX) -> R
	return function(random, min, max)
		return f(SET_STATE, min, max, random)
	end
end

local function CREATE_METHOD_RANGE<MIN, MAX, R>(
	f: MinMaxMethod<number, MIN, MAX, R>
): (MIN, MAX, number?) -> R
	return function(min, max, seed)
		return f(SET_GSTATE, min, max, seed or SEED)
	end
end

local function CREATE_METHOD_NO_ARG<S, R>(
	f: (SetState<S>, S) -> R, set_state: SetState<S>
): (S) -> R
	return function(s)
		return f(set_state, s)
	end
end

local function INTEGER<S>(
	set_state: SetState<S>, min: number, max: number, state: S
): number
	set_state(state)
	-- if math.random isnt wrapped here it wont inline
	return (math.random(min, max))
end

local function FLOAT<S>(
	set_state: SetState<S>, min: number, max: number, state: S
): number
	set_state(state)
	return min + (math.random() * (max - min))
end

local function VALUE<S>(set_state: SetState<S>, t: { unknown }, state: S): unknown
	return t[INTEGER(set_state, 1, #t, state)]
end

local function SHUFFLE<S>(set_state: SetState<S>, t: { unknown }, state: S)
	for index, value in t do
		local random_index = INTEGER(set_state, index, #t, state)
		t[index] = t[random_index]
		t[random_index] = value
	end
end

local function EXPORT_INTEGER<S>(
	set_state: SetState<S>, min: number?, max: number?, state: S
): number
	return INTEGER(set_state, min or DEFAULT_MIN, max or DEFAULT_MAX, state)
end

local function EXPORT_FLOAT<S>(
	set_state: SetState<S>, min: number?, max: number?, state: S
): number
	return FLOAT(set_state, min or DEFAULT_MIN, max or DEFAULT_MAX, state)
end

local function VECTOR<S>(
	set_state: SetState<S>, min: vector?, max: vector?, state: S
): vector
	max = max or DEFAULT_MAX_VECTOR
	min = min or DEFAULT_MIN_VECTOR

	return vector.create(
		FLOAT(set_state, min.x, max.x, state),
		FLOAT(set_state, min.y, max.y, state),
		FLOAT(set_state, min.z, max.z, state)
	)
end

local function NORMALIZED_VECTOR<S>(
	set_state: SetState<S>, state: S
): vector
	--[[
		doing this because normalized_vector is weird and only takes in a seed or random arg
		so its just easier to do defaults here + if its a method on the class the first arg should never be nil
	]]
	if not state then
		state = SEED :: any
	end

	set_state(state)
	local theta = math.random() * math.pi * 2
	set_state(state)
	local u = math.random() * 2 - 1
	local c = math.sqrt(1 - u * u)

	return vector.create(
		c * math.cos(theta),
		u,
		c * math.sin(theta)
	)
end

local random_prototype: RandomPrototype = {
	normalized_vector = CREATE_METHOD_NO_ARG(NORMALIZED_VECTOR, SET_STATE),
	integer = CREATE_CLASS_METHOD_RANGE(EXPORT_INTEGER),
	float = CREATE_CLASS_METHOD_RANGE(EXPORT_FLOAT),
	vector = CREATE_CLASS_METHOD_RANGE(VECTOR),
	shuffle = CREATE_CLASS_METHOD(SHUFFLE) :: any,
	value = CREATE_CLASS_METHOD(VALUE) :: any,
	__index = nil :: any,
}

local random: Export = {
	normalized_vector = CREATE_METHOD_NO_ARG(NORMALIZED_VECTOR, SET_GSTATE),
	integer = CREATE_METHOD_RANGE(EXPORT_INTEGER),
	float = CREATE_METHOD_RANGE(EXPORT_FLOAT),
	vector = CREATE_METHOD_RANGE(VECTOR),
	shuffle = CREATE_METHOD(SHUFFLE),
	value = CREATE_METHOD(VALUE),
} :: Export

function random.create(seed)
	return setmetatable({
		state = seed or CREATE_SEED()
	}, random_prototype)
end

function random.get_seed()
	return SEED
end

do

	random_prototype.__index = random_prototype
	table.freeze(random_prototype)
	table.freeze(random)

end

return random :: Export


local testkit = require("@testkit")
local random = require("./init")

local TEST, CASE, CHECK, FINISH = testkit.test()
local CAT_SOUNDS = { "meow", "mrrp", "mrow" }

local function HAS_SAME_VALUES(a: { unknown }, b: { unknown }): boolean
	for _, value in a do
		if not table.find(a, value) then
			return false
		end
	end

	for _, value in b do
		if not table.find(b, value) then
			return false
		end
	end

	return true
end

TEST("random", function()

	do CASE("integer")
		CHECK(type(random.integer()) == "number")
	end

	do CASE("shuffle")
		local new_sounds = table.clone(CAT_SOUNDS)

		random.shuffle(new_sounds)
		CHECK(HAS_SAME_VALUES(CAT_SOUNDS, new_sounds))
	end

	do CASE("value")
		CHECK(table.find(CAT_SOUNDS, random.value(CAT_SOUNDS)))
	end

	do CASE("float")
		CHECK(type(random.float()) == "number")
	end

	do CASE("float min max")
		local min, max = .5, .7
		local n = random.float(min, max)
		CHECK(type(n) == "number" and math.clamp(n, min, max) == n)
	end

	do CASE("integer")
		CHECK(type(random.integer()) == "number")
	end

	do CASE("integer min max")
		local min, max = 0, 10
		local n = random.integer(min, max)
		CHECK(type(n) == "number" and math.clamp(n, min, max) == n and math.floor(n) == n)
	end

	do CASE("vector")
		CHECK(type(random.vector()) == "vector")
	end

	do CASE("vector min max")
		local min, max = vector.zero, vector.one
		local v = random.vector(min, max)
		CHECK(type(v) == "vector" and vector.clamp(v, min, max) == v)
	end

	do CASE("normalized_vector")
		CHECK(type(random.normalized_vector()) == "vector")
	end

	do CASE("get_seed")
		CHECK(type(random.get_seed()) == "number")
	end

	do CASE("create class")
		local random = random.create(1)
		CHECK(random.state == 1)
	end

	do CASE("create class seed")
		local random = random.create()
		CHECK(type(random.state) == "number")
	end

	-- random class fields cases
	do
		local skip_fields = { random.create, random.get_seed }
		local c = random.create()

		for name, f in random do
			if table.find(skip_fields, f)  then
				continue
			end

			CASE(`class:{name}`)
			local cf = (c :: any)[name] :: (...any) -> any

			if f == random.value then
				CHECK(type(cf(c, CAT_SOUNDS)) == type(f(CAT_SOUNDS)))
			elseif f == random.shuffle then
				local a, b = table.clone(CAT_SOUNDS), table.clone(CAT_SOUNDS)

				cf(c, a)
				f(b)

				CHECK(HAS_SAME_VALUES(a, b) and HAS_SAME_VALUES(a, CAT_SOUNDS))
			else
				CHECK(type(cf(c)) == type(f()))
			end
		end
	end
end)


--!native

--[[
	rbx deeplink
	utility functions for dealing w roblox deeplinks
]]

local url = require("@libs/url")

local REGULAR_FORMAT = "https://www.roblox.com/games/start?placeId=%*&launchData=%*"
local APPSFLYER_FORMAT = "https://ro.blox.com/Ebh5?af_dp=%*&af_web_dp=%*"
local DATA_LEN_LIMIT = 200
local DATA_LEN_ERROR = `[RBX DEEPLINK] data provided exceeds {DATA_LEN_LIMIT} characters`
local FORMAT = string.format
local MATCH = string.match
local DECODE = url.decode
local ENCODE = url.encode

local rbxdeeplink = {}

function rbxdeeplink.get_launchdata(player: Player): string?
	local launch_data = player:GetJoinData().LaunchData

	if launch_data and #launch_data ~= 0 then
		local decoded = DECODE(launch_data)
		-- pattern for getting a string with a non zero length
		-- right after "&launchdata=" that doesn't include the & character
		return MATCH(decoded, "&launchdata=([^&]+)")
	else
		return nil
	end
end

function rbxdeeplink.format_appsflyer(
	placeid: number,
	data: string,
	encode_spaces_as: url.SpaceEncoding?
): string
	if #data > DATA_LEN_LIMIT then
		error(DATA_LEN_ERROR)
	else
		local regular = ENCODE(FORMAT(REGULAR_FORMAT, placeid, data), encode_spaces_as)
		return FORMAT(APPSFLYER_FORMAT, regular, regular)
	end
end

function rbxdeeplink.format(
	placeid: number,
	data: string,
	encode_spaces_as: url.SpaceEncoding?
): string
	if #data > DATA_LEN_LIMIT then
		error(DATA_LEN_ERROR)
	else
		return FORMAT(REGULAR_FORMAT, placeid, ENCODE(data, encode_spaces_as))
	end
end

return table.freeze(rbxdeeplink)


local testkit = require("@testkit")
local rbxdeeplink = require("./init")
local url = require("../url")

local TEST, CASE, CHECK, FINISH = testkit.test()

TEST("rbxdeeplink", function()

	do CASE("get_launchdata")
		local launchdata = "https://libs.luau.lol/lmao&meow=mrrp&mrrp=meow&launchdata=kitty catters unite now!"
		local mock_player = table.freeze({
			GetJoinData = function()
				return table.freeze({
					LaunchData = launchdata
				})
			end
		})

		CHECK(rbxdeeplink.get_launchdata(mock_player :: any) == "kitty catters unite now!")
	end

	do CASE("format")
		CHECK(rbxdeeplink.format(0, "meow") == "https://www.roblox.com/games/start?placeId=0&launchData=meow")
	end

	do CASE("format appsflyer")
		local regular = url.encode("https://www.roblox.com/games/start?placeId=0&launchData=meow")
		CHECK(rbxdeeplink.format_appsflyer(0, "meow") == `https://ro.blox.com/Ebh5?af_dp={regular}&af_web_dp={regular}`)
	end

end)


--[[
	rbxsafeconn
	utility for safely connecting to commonly used events where you'd also iterate through some instances
]]

local CollectionService = game:GetService("CollectionService")
local Players = game:GetService("Players")
local character = require("@libs/character")

local PLAYER_ADDED = Players.PlayerAdded

local rbx_event = {}

-- uses a generic so u can cast instance in the callback to any instance,
-- as generally u know what instance a tag takes in, and the callback would probably error anyways
-- if it didn't take in that specific instance
function rbx_event.tag_added<I>(tag: string, f: (instance: I & Instance) -> ()): () -> ()
	local conn = CollectionService:GetInstanceAddedSignal(tag):Connect(f :: any)

	for _, instance: any in CollectionService:GetTagged(tag) do
		task.spawn(f, instance)
	end

	return function()
		conn:Disconnect()
	end
end

function rbx_event.player_added(
	f: (player: Player & { Character: character.Character? }) -> ()
): () -> ()
	local conn = PLAYER_ADDED:Connect(f :: any)

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

	return function()
		conn:Disconnect()
	end
end

return table.freeze(rbx_event)


--[[
	rbxthumb
	module for easily creating roblox thumbnail urls, based on Nevermore's rbxthumbutil
	although with types
]]

type PlayerThumbnailFormatter<S> = (player: Player, size: S?, circular: boolean?) -> Content
type ThumbnailFormatter<S> = (id: number, size: S?, circular: boolean?) -> Content

export type ThumbnailType = "Asset" | "Avatar" | "AvatarHeadShot" | "BadgeIcon"  |
	"BundleThumbnail" | "GameIcon" | "GamePass" | "GroupIcon" | "Outfit" | "FontFamily" |
	"AvatarBust"

export type ThumbnailSize = "1200×80" | "720x720" | "420x420" |"352x352" | "150x150" |
	"100x100" | "60x60" | "50x50" | "48x48" | "180x180"

type AvatarHeadshotThumbnailSize = "48×48" | "60×60" | "100×100" | "150×150" | "180×180" |
	"352×352" | "420×420"

type AvatarBustThumbnailSize = "100x100" | "150x15" | "75×75" | "100×100" | "150×150" | "180×180" |
	"352×352" | "420×420"

type FullAvatarThumbnailSize = "50×50" | "60×60" | "75×75" | "100×100" | "150×150" | "180×180" |
	"352×352" | "420×420"

local BASE_URI = "rbxthumb://type=%*&id=%*&w=%*&h=%*"
local CIRCULAR_URI = `{BASE_URI}&filters=circular`
local ASSET_ENUM = Enum.AvatarItemType.Asset
local CREATE_CONTENT = Content.fromUri
local BUNDLE_STR = "BundleThumbnail"
local SPLIT_PATTERN = "(%*)x(%*)"
local FORMAT = string.format
local DEFUALT_SIZE_NUM = 150
local MATCH = string.match
local ASSET_STR = "Asset"

local function RAW_FORMAT(
	id: number,
	type: ThumbnailType,
	width: number | string,
	height: number | string,
	circular: boolean?
): Content
	return CREATE_CONTENT(FORMAT(
		if circular then CIRCULAR_URI else BASE_URI,
		type,
		id,
		width,
		height
	))
end

local function SPLIT_SIZE(
	size: ThumbnailSize?,
	default_width: number,
	default_height: number
): (number | string, number | string)
	if size then
		local width, height = MATCH(size, SPLIT_PATTERN)
		return width or default_width, height or default_height
	else
		return default_width, default_height
	end
end

local function FORMAT_RBXTHUMB_URL(
	id: number,
	type: ThumbnailType,
	size: ThumbnailSize?,
	circular: boolean?
): Content
	local width, height = SPLIT_SIZE(size, DEFUALT_SIZE_NUM, DEFUALT_SIZE_NUM)
	return RAW_FORMAT(id, type, width, height, circular)
end

local function CREATE_PLAYER_FORMATTER<S>(
	type: ThumbnailType,
	default_width: number?,
	default_height: number?
): PlayerThumbnailFormatter<S>
	local defualt_height = default_height or DEFUALT_SIZE_NUM
	local defualt_width = default_width or DEFUALT_SIZE_NUM

	return function(player: Player, size: S?, circular: boolean?): Content
		local width, height = SPLIT_SIZE(size :: any, defualt_width, defualt_height)
		return ( RAW_FORMAT(player.UserId, type, width, height, circular) )
	end
end

local function CREATE_FORMATTER<S>(
	type: ThumbnailType,
	default_width: number?,
	default_height: number?
): ThumbnailFormatter<S>
	local defualt_height = default_height or DEFUALT_SIZE_NUM
	local defualt_width = default_width or DEFUALT_SIZE_NUM

	return function(id: number, size: S?, circular: boolean?): Content
		local width, height = SPLIT_SIZE(size :: any, defualt_width, defualt_height)
		return ( RAW_FORMAT(id, type, width, height, circular) )
	end
end

local rbxthumb = {
	player_headshot = CREATE_PLAYER_FORMATTER("AvatarHeadShot") :: PlayerThumbnailFormatter<AvatarHeadshotThumbnailSize>,
	player_bust = CREATE_PLAYER_FORMATTER("AvatarBust") :: PlayerThumbnailFormatter<AvatarBustThumbnailSize>,
	avatar_headshot = CREATE_FORMATTER("AvatarHeadShot") :: ThumbnailFormatter<AvatarHeadshotThumbnailSize>,
	player_full = CREATE_PLAYER_FORMATTER("Avatar") :: PlayerThumbnailFormatter<FullAvatarThumbnailSize>,
	avatar_bust = CREATE_FORMATTER("AvatarBust") :: ThumbnailFormatter<AvatarBustThumbnailSize>,
	bundle = CREATE_FORMATTER("BundleThumbnail") :: ThumbnailFormatter<"150x150" | "420x420">,
	avatar_full = CREATE_FORMATTER("Avatar") :: ThumbnailFormatter<FullAvatarThumbnailSize>,
	fontfamily = CREATE_FORMATTER("FontFamily", 1200, 80) :: ThumbnailFormatter<"1200×80">,
	group = CREATE_FORMATTER("GroupIcon") :: ThumbnailFormatter<"150x150" | "420x420">,
	outfit = CREATE_FORMATTER("Outfit") :: ThumbnailFormatter<"150x150" | "420x420">,
	asset = CREATE_FORMATTER("Asset") :: ThumbnailFormatter<"150x150" | "420x420">,
	experience = CREATE_FORMATTER("GameIcon"):: ThumbnailFormatter<"150x150">,
	gamepass = CREATE_FORMATTER("GamePass") :: ThumbnailFormatter<"150x150">,
	badge = CREATE_FORMATTER("BadgeIcon") :: ThumbnailFormatter<"150x150">,
}

function rbxthumb.avatar_item(
	avatar_item_type: Enum.AvatarItemType,
	id: number,
	size: ("150x150" | "420x420")?,
	circular: boolean?
): Content
	return if avatar_item_type == ASSET_ENUM then
		FORMAT_RBXTHUMB_URL(id, ASSET_STR :: any, size, circular)
	else
		FORMAT_RBXTHUMB_URL(id, BUNDLE_STR :: any, size, circular)
end

return table.freeze(rbxthumb)


local testkit = require("@testkit")
local rbxthumb = require("./init")

local TEST, CASE, CHECK, FINISH = testkit.test()

local BASE_URL = "rbxthumb://type=%s&id=%d&w=%d&h=%d"
local CIRCULAR_URL = `{BASE_URL}&filters=circular`

local PLAYER_AUTO_GENERATED_METHODS: { [string]: (player: { UserId: number }, size: string?, circular: boolean?) -> string } = {
	AvatarHeadshot = rbxthumb.player_headshot,
	AvatarBust = rbxthumb.player_bust,
	Avatar = rbxthumb.player_full,
} :: any

local AUTO_GENERATED_METHODS: { [string]: (id: number, size: string?, circular: boolean?) -> string } = {
	AvatarHeadshot = rbxthumb.avatar_headshot,
	AvatarBust = rbxthumb.avatar_bust,
	Avatar = rbxthumb.avatar_full,
	BundleThumbnail = rbxthumb.bundle,
	GroupIcon = rbxthumb.group,
	Outfit = rbxthumb.outfit,
	Asset = rbxthumb.asset,
	GameIcon = rbxthumb.experience,
	GamePass = rbxthumb.gamepass,
	BadgeIcon = rbxthumb.badge
} :: any

local DUMMY_PLAYER = {
	UserId = 1234,
}

TEST("rbx thumb", function()
	do CASE("check formats")
		CHECK(
			rbxthumb.urlformats.base == BASE_URL and
			rbxthumb.urlformats.circular == CIRCULAR_URL
		)
	end

	do CASE("avatar item")
		local avatar_item = rbxthumb.avatar.item
		-- using len because string eq had issues??? as in the first iterator it would always fail
		-- even tho they were both the same??
		CHECK(
			#avatar_item(Enum.AvatarItemType.Asset, 1234, "420x420") == #string.format(BASE_URL, "Asset", 1234, 420, 420)
		)
		CHECK(
			#avatar_item(Enum.AvatarItemType.Bundle, 1234, "420x420") == #string.format(BASE_URL, "BundleThumbnail", 1234, 420, 420)
		)
	end

	do CASE("avatar item type to thumbnail type")
		CHECK(rbxthumb.avatar_item_type_to_thumbnail_type(Enum.AvatarItemType.Asset) == "Asset")
		CHECK(rbxthumb.avatar_item_type_to_thumbnail_type(Enum.AvatarItemType.Bundle) == "BundleThumbnail")
	end

	do CASE("font family")
		CHECK(#rbxthumb.fontfamily(1234, "1200x80" :: any) == #string.format(BASE_URL, "FontFamily", "1234", "1200", "80"))
		CHECK(#rbxthumb.fontfamily(1234, "1200x80" :: any, true) == #string.format(CIRCULAR_URL, "FontFamily", "1234", "1200", "80"))
	end

	do CASE("generated methods")
		for type, method in AUTO_GENERATED_METHODS do
			CHECK(#method(1234, "150x150") == #string.format(BASE_URL, type, 1234, 150, 150))
			CHECK(#method(1234, "150x150", true) == #string.format(CIRCULAR_URL, type, 1234, 150, 150))
		end
	end

	do CASE("player generated methods")
		for type, method in PLAYER_AUTO_GENERATED_METHODS do
			CHECK(#method(DUMMY_PLAYER, "150x150") == #string.format(BASE_URL, type, 1234, 150, 150))
			CHECK(#method(DUMMY_PLAYER, "150x150", true) == #string.format(CIRCULAR_URL, type, 1234, 150, 150))
		end
	end
end)


--!native

--[[
	Retryer
	Utility for retrying functions easily
]]

local cross = require("@libs/cross")

local WAIT = cross.wait

local function INSURE_MAX_RETRIES_GREATER_THAN_ONE(max_retries: number)
	if max_retries <= 1 then
		error("[RETRYER]: max_retries cannot be less than 2")
	end
end

local retryer = {}

--[[
	Retries the function with the given arguments `...` using [pcall](https://luau.org/library#global-functions)
	until it no-longer errors, or if `max_retries` has been hit.

	If the function succeeds, returns true and the results of the function.
	If the function still raises an error after `max_retries` was hit, returns false and the error string.
]]
function retryer.retry<A..., R...>(
	max_retries: number,
	f: (A...) -> (R...),
	...: A...
): (boolean, R...)
	INSURE_MAX_RETRIES_GREATER_THAN_ONE(max_retries)
	local results: { any } = { pcall(f, ...) }
	local success = results[1]

	if not success then
		local attempts = 1

		repeat
			WAIT()
			results = { pcall(f, ...) }
			success = results[1]
			attempts += 1
		until attempts == max_retries or success
	end
	return success, table.unpack(results, 2)
end

--[[
	Retries the function with the given arguments `...` using [pcall](https://luau.org/library#global-functions)
	until it no-longer errors indefinitely.
	With a `delay` between retries.

	Returns the results of the function.
]]
function retryer.infinite_delay<A..., R...>(delay: number, f: (A...) -> (R...), ...: A...): R...
	local results: { any } = { pcall(f, ...) }
	local success = results[1]

	if not success then
		repeat
			WAIT(delay)
			results = { pcall(f, ...) }
			success = results[1]
		until success
	end
	return table.unpack(results, 2)
end

--[[
	Retries the function with the given arguments `...` using [pcall](https://luau.org/library#global-functions)
	until it no-longer errors, or if `max_retries` has been hit.
	With a `delay` between retries that exponentially increases by `delay_exponent` each retry.

	Returns the results of the function.
]]
function retryer.infinite_exponent<A..., R...>(
	delay: number,
	delay_exponent: number,
	f: (A...) -> (R...),
	...: A...
): R...
	local results: { any } = { pcall(f, ...) }
	local success = results[1]

	if not success then
		local attempts = 1

		repeat
			WAIT(delay + (delay_exponent ^ attempts))
			results = { pcall(f, ...) }
			success = results[1]
			attempts += 1
		until success
	end
	return table.unpack(results, 2)
end

--[[
	Retries the function with the given arguments `...` using [pcall](https://luau.org/library#global-functions)
	until it no-longer errors, or if `max_retries` has been hit.
	With a `delay` between retries.

	If the function succeeds, returns true and the results of the function.
	If the function still raises an error after `max_retries` was hit, returns false and the error string.
]]
function retryer.delay<A..., R...>(
	delay: number,
	max_retries: number,
	f: (A...) -> (R...),
	...: A...
): (boolean, R...)
	INSURE_MAX_RETRIES_GREATER_THAN_ONE(max_retries)
	local results: { any } = { pcall(f, ...) }
	local success = results[1]

	if not results[1] then
		local attempts = 1

		repeat
			WAIT(delay)
			results = { pcall(f, ...) }
			success = results[1]
			attempts += 1
		until success or attempts == max_retries
	end
	return success, table.unpack(results, 2)
end

--[[
	Retries the function with the given arguments `...` using [pcall](https://luau.org/library#global-functions)
	until it no-longer errors, or if `max_retries` has been hit.
	With a `delay` between retries that exponentially increases by `delay_exponent` each retry.

	If the function succeeds, returns true and the results of the function.
	If the function still raises an error after `max_retries` was hit, returns false and the error string.
]]
function retryer.exponent<A..., R...>(
	delay: number,
	delay_exponent: number,
	max_retries: number,
	f: (A...) -> (R...),
	...: A...
): (boolean, R...)
	INSURE_MAX_RETRIES_GREATER_THAN_ONE(max_retries)
	local results: { any } = { pcall(f, ...) }
	local success = results[1]

	if not results[1] then
		local attempts = 1

		repeat
			WAIT(delay + (delay_exponent ^ attempts))
			results = { pcall(f, ...) }
			success = results[1]
			attempts += 1
		until success or attempts == max_retries
	end
	return success, table.unpack(results, 2)
end

--[[
	Retries the function with the given arguments `...` using [pcall](https://luau.org/library#global-functions)
	until it no-longer errors indefinitely.

	Returns the results of the function.
]]
function retryer.infinite<A..., R...>(f: (A...) -> (R...), ...: A...): R...
	local results: { any } = { pcall(f, ...) }
	local success = results[1]

	if not success then
		repeat
			WAIT()
			results = { pcall(f, ...) }
			success = results[1]
		until success
	end
	return table.unpack(results, 2)
end

return table.freeze(retryer)


local testkit = require("@testkit")
local retryer = require("@libs/retryer")

local TEST, CASE, CHECK, FINISH = testkit.test()
local NOOP = function() end
local ATTEMPTS = 0

local function ENDS_WITH(s: string, ends_with: string): boolean
	return string.sub(s, #s - #ends_with + 1, #s) == ends_with
end

local function meower(kitty: string, catter: string): (string, string)
	ATTEMPTS += 1

	if ATTEMPTS % 2 ~= 0 or ATTEMPTS == 2 then
		error("lmao")
	end

	return `{kitty} :3`, `{catter} 🐈`
end

TEST("retryer", function()
	do CASE("retry errors if max_retries is 1")
		local success, retryer_error = pcall(retryer.retry, 1, NOOP)

		CHECK(
			success == false and
			ENDS_WITH(retryer_error, "[RETRYER]: max_retries cannot be less than 2")
		)
	end

	do CASE("delay errors if max_retries is 1")
		local success, retryer_error = pcall(retryer.delay, 2, 1, NOOP)

		CHECK(
			success == false and
			ENDS_WITH(retryer_error, "[RETRYER]: max_retries cannot be less than 2")
		)
	end

	do CASE("exponent errors if max_retries is 1")
		local success, retryer_error = pcall(retryer.exponent, 2, 2, 1, NOOP)

		CHECK(
			success == false and
			ENDS_WITH(retryer_error, "[RETRYER]: max_retries cannot be less than 2")
		)
	end

	do CASE("retry")
		local success1, ret1_1, ret1_2 = retryer.retry(2, meower, "meow", "mrrow")
		local success2, ret2_1, ret2_2 = retryer.retry(4, meower, "meow", "mrrow")

		CHECK(
			success1 == false and ENDS_WITH(ret1_1, "lmao") and ret1_2 == nil and
			success2 == true and ret2_1 == "meow :3" and ret2_2 == "mrrow 🐈"
		)
	end

	do CASE("delay")
		ATTEMPTS = 0
		local success1, ret1_1, ret1_2 = retryer.delay(2, 2, meower, "meow", "mrrow")
		local success2, ret2_1, ret2_2 = retryer.delay(2, 4, meower, "meow", "mrrow")

		CHECK(
			success1 == false and ENDS_WITH(ret1_1, "lmao") and ret1_2 == nil and
			success2 == true and ret2_1 == "meow :3" and ret2_2 == "mrrow 🐈"
		)
	end

	do CASE("exponent")
		ATTEMPTS = 0
		local success1, ret1_1, ret1_2 = retryer.exponent(2, .5, 2, meower, "meow", "mrrow")
		local success2, ret2_1, ret2_2 = retryer.exponent(2, .5, 4, meower, "meow", "mrrow")

		CHECK(
			success1 == false and ENDS_WITH(ret1_1, "lmao") and ret1_2 == nil and
			success2 == true and ret2_1 == "meow :3" and ret2_2 == "mrrow 🐈"
		)
	end

	do CASE("infinite")
		local ret1_1, ret1_2 = retryer.infinite(meower, "meow", "mrrow")
		CHECK(ret1_1 == "meow :3" and ret1_2 == "mrrow 🐈")
	end

	do CASE("infinite_delay")
		local ret1_1, ret1_2 = retryer.infinite_delay(1, meower, "meow", "mrrow")
		CHECK(ret1_1 == "meow :3" and ret1_2 == "mrrow 🐈")
	end

	do CASE("infinite_exponent")
		local ret1_1, ret1_2 = retryer.infinite_exponent(1, .5, meower, "meow", "mrrow")
		CHECK(ret1_1 == "meow :3" and ret1_2 == "mrrow 🐈")
	end
end)


--[[
	safe teleport
	wrapper for TeleportService:TeleportAsync() with automatic retries, and a better typed api
	based on the SafeTeleport example function on Roblox's docs:
	https://create.roblox.com/docs/projects/teleport#handle-failed-teleports
]]

local TeleportService = game:GetService("TeleportService")
local retryer = require("@libs/retryer")

type BaseTeleportDataValue = string | buffer | Vector2 | vector | Vector3int16 | EnumItem | UDim | UDim2 | Rect |
	NumberRange | Vector2int16 | Color3 | Axes | DateTime | Random | Font | Faces | Content | Vector3

type TeleportDataValue = BaseTeleportDataValue | number

--[[
	Structure of Data to be passed to the destination with the player(s).

	[Learn More](https://create.roblox.com/docs/projects/teleport#send-user-data-along-with-teleports)
]]
export type TeleportData =
	| { [BaseTeleportDataValue]: TeleportData | TeleportDataValue }
	| { [number]: TeleportData | TeleportDataValue }

--[[
	Returned information by Safe Teleport if `TeleportInfo` was passed to Safe Teleport.
]]
export type TeleportResult = {
	--[[
		The private server ID of the reserved server that the player(s) are being teleported to.

		[Learn More](https://create.roblox.com/docs/reference/engine/classes/TeleportAsyncResult#PrivateServerId)
	]]
	read private_server_id: string,
	--[[
		The access code of the reserved server that the player(s) are being teleported to.

		[Learn More](https://create.roblox.com/docs/reference/engine/classes/TeleportAsyncResult#ReservedServerAccessCode)
	]]
	read access_code: string,
}

type BaseTeleportInfo<RS, AC, JI> = {
	--[[
		Flag to indicate if a reserved server should be
		allocated and the player(s) should then be teleported to
		the allocated server.

		[Learn More](https://create.roblox.com/docs/reference/engine/classes/TeleportOptions#ShouldReserveServer)
	]]
	reserve_server: RS,
	--[[
		The reserved server access code that indicates the
		reserved server that the players(s) should be teleported to.

		[Learn More](https://create.roblox.com/docs/reference/engine/classes/TeleportOptions#ReservedServerAccessCode)
	]]
	access_code: AC,
	--[[
		Data to be passed to the destination with the player(s).

		[Learn More](<https://create.roblox.com/docs/reference/engine/classes/TeleportOptions#SetTeleportData>)
	]]
	data: TeleportData?,
	--[[
		The [`DataModel.JobId`](https://create.roblox.com/docs/reference/engine/classes/DataModel#JobId) of the server instance to
		teleport to.

		[Learn More](https://create.roblox.com/docs/reference/engine/classes/TeleportOptions#ServerInstanceId)
	]]
	jobid: JI,
}

--[[
	Optional input parameters to Safe Teleport.

	[Learn More](https://create.roblox.com/docs/reference/engine/classes/TeleportOptions)
]]
export type TeleportInfo =
	| BaseTeleportInfo<nil, nil, string>
	| BaseTeleportInfo<nil, string, nil>
	| BaseTeleportInfo<true, nil, nil>
	| BaseTeleportInfo<nil, nil, nil>

type SafeTeleportPrototype = {
	__call:
		& ((
			safe_teleport: SafeTeleport, placeid: number, players_or_player: {Player} | Player
		) -> boolean)
		& ((
			safe_teleport: SafeTeleport, placeid: number, players_or_player: {Player} | Player, info: TeleportInfo
		) -> (boolean, TeleportResult))
}

--[[
	Wrapper for [`TeleportService:TeleportAsync()`](https://create.roblox.com/docs/reference/engine/classes/TeleportService#TeleportAsync) with automatic retries, and a better typed API.
]]
type SafeTeleport = typeof(setmetatable({} :: {
	--[[
		Amount of times Safe Teleport should attempt to teleport before giving up.
		(Default: 5)
	]]
	attempt_limit: number,
	--[[
		Time in seconds between teleport attempts by Safe Teleport.
		(Default: 1)
	]]
	retry_delay: number,
	--[[
		Time in seconds Safe Teleport should wait before retrying a teleport, if [`TeleportService`](https://create.roblox.com/docs/reference/engine/classes/TeleportService) became [flooded](https://create.roblox.com/docs/reference/engine/enums/TeleportResult#Flooded).
		(Default: 15)
	]]
	flood_delay: number,
}, {} :: SafeTeleportPrototype))

local TELEPORT_ASYNC = TeleportService.TeleportAsync
local FLOODED_ENUM = Enum.TeleportResult.Flooded
local FAILURE_ENUM = Enum.TeleportResult.Failure
local ATTEMPT_LIMIT = 5
local FLOOD_DELAY = 15
local RETRY_DELAY = 1

local function TELEPORT(placeid: number, players: { Player }, opts: TeleportOptions?): (boolean, TeleportResult?)
	local success, result: TeleportAsyncResult? = retryer.delay(
		RETRY_DELAY, ATTEMPT_LIMIT, TELEPORT_ASYNC,
		TeleportService, placeid, players, opts
	)

	if success then
		if result then
			return true, table.freeze({
				access_code = result.ReservedServerAccessCode,
				private_server_id = result.PrivateServerId,
			})
		end

		return true, nil
	else
		warn(`[SAFE TELEPORT]: teleport failed\n\tteleport_async_err: {result}`)
		return false, nil
	end
end

local safe_teleport_mt = {} :: SafeTeleportPrototype
local safe_teleport: SafeTeleport = setmetatable({
	attempt_limit = ATTEMPT_LIMIT,
	flood_delay = FLOOD_DELAY,
	retry_delay = RETRY_DELAY,
}, safe_teleport_mt)

function safe_teleport_mt.__call(safe_teleport, placeid, players_or_player, info)
	local players = if type(players_or_player) == "table" then players_or_player else { players_or_player }

	if not table.isfrozen(safe_teleport) then
		ATTEMPT_LIMIT = safe_teleport.attempt_limit
		FLOOD_DELAY = safe_teleport.flood_delay
		RETRY_DELAY = safe_teleport.retry_delay
		table.freeze(safe_teleport)
	end

	if info then
		local options = Instance.new("TeleportOptions")

		if info.reserve_server then
			options.ShouldReserveServer = info.reserve_server
		elseif info.access_code then
			options.ReservedServerAccessCode = info.access_code
		elseif info.jobid then
			options.ServerInstanceId = info.jobid
		end

		if info.data then
			options:SetTeleportData(info.data)
		end
		return TELEPORT(placeid, players, options)
	else
		return TELEPORT(placeid, players)
	end
end

do

	TeleportService.TeleportInitFailed:Connect(function(
		player, result, teleport_error, placeid, options
	)
		if result == FLOODED_ENUM then
			task.wait(FLOOD_DELAY)
		elseif result == FAILURE_ENUM then
			task.wait(RETRY_DELAY)
		else
			-- if the teleport is invalid, report the error instead of retrying
			error(`[SAFE TELEPORT]: Teleport Init Failed\n\tresult: {result.Name}\n\terror: {teleport_error}`)
		end

		TELEPORT(placeid, { player }, options)
	end)

	table.freeze(safe_teleport_mt)

end

return safe_teleport


--!native

--[[
	stack tree
	library for generating tree structures for nodes on a shelf
	to prevent floating nodes from ever happening
]]

local random = require("@libs/random")

export type FaceTowardsSide = "Front" | "Back" | "Left" | "Right"
-- TaperType names are referring to roof types, ie bloxburg roof types
export type TaperType = "Pyramid" | "Slope" | "Flat" | "Hip"

type BaseNode<HOS, S, N> = {
	--[[
		Indicates if this node can be grabbed, as in there are no taken nodes above this node and this node itself is also taken.
	]]
	takeable: boolean,
	--[[
		Indicates if the node has only a single supporter, this is purely internal
		and only used by `stack_tree.closest_empty_node`,
		as an optimization for trees that have a taper type of "Flat" or "Slope".
	]]
	has_one_supporter: HOS,
	--[[
		Array of nodes that are supported by this node, if `nil` this node is at the bottom of the tree.
	]]
	supported: { N }?, -- not using the node type here because then the recursive type error comes to haunt me
	--[[
		The position this node is at within the DataModel
	]]
	position: vector,
	--[[
		Indicates if this node is taken,
	]]
	taken: boolean,
	--[[
		The CFrame this node is at within the DataModel
	]]
	cframe: CFrame,
	--[[
		Either an array of nodes that are supported by this node, or if `has_one_supporter` is true this points to a node directly below this node,
		and if `nil` this node is at the top of the tree.
	]]
	supporter: S?,
}

export type Node = BaseNode<true, Node, Node> | BaseNode<false, { Node }, Node>

export type Tree = {
	current_stock: number,
	max_stock: number,
	position: vector,
	nodes: { Node },
}

type BaseRandomRotationInfo<RRN, RRMM> = {
	-- if set it can only randomly rotate the nodes within the range given
	random_rotation_min_max: RRMM,
	randomly_rotate_nodes: RRN,
}

type RandomRotationInfo =
	| BaseRandomRotationInfo<true, NumberRange?>
	| BaseRandomRotationInfo<false, nil>

type BaseTaperInfo<T, TTLTT, FTTS> = {
	-- This option will only work if TaperType == "Slope" or "Hip", where it will make the slope face that side
	face_taper_towards_side: FTTS?,
	-- If given BoxFill will only taper the the layers after the Index (TotalLayers - TotalTopLayersToTaper)
	-- NOTE: if TotalLayers - TotalTopLayersToTaper is not greater than 1 then
	-- BoxFill will error as indexes cant be less than 1
	-- and you cant taper the first layer
	total_top_layers_to_taper: TTLTT,
	taper_type: T,
}

type TaperInfo =
	| BaseTaperInfo<"Slope", number?, FaceTowardsSide>
	| BaseTaperInfo<"Hip", number?, FaceTowardsSide>
	| BaseTaperInfo<"Pyramid", number?, nil>
	| BaseTaperInfo<"Flat"?, nil, nil>

export type StackFillInfo = RandomRotationInfo & TaperInfo & {
	node_rotation_offset: vector?,
	padding: (Vector2 | vector)?,
	max_nodes_xyz: vector?,
	taper_type: TaperType?,
	should_stack: boolean?,
	box_cframe: CFrame?,
	node_size: vector,
	box_size: vector,
}

type NodeConnector = (
	node_cframe: CFrame,
	node_size: vector,
	layer_below: { Node },
	face_towards_side: FaceTowardsSide
) -> { Node }

type LayerEditor = (
	bottom_box_cframe: CFrame,
	box_size: vector,
	node_size: vector,
	index: number,
	face_towards_side: FaceTowardsSide,
	half_box_size: vector
) -> (CFrame, vector)

type TaperTransformer = {
	read node_connector: NodeConnector,
	read layer_editor: LayerEditor,
}

local DEFUALT_RANDOM_ROTATE_MIN_MAX = NumberRange.new(0, 10)

local function GET_NODES_CLOSEST_TO_POSITION(
	previous_layer: { Node }, position: vector, node_amount: number
): { Node }
	local nodes = table.create(node_amount)

	for index = 1, node_amount do
		local closest_magnitude = math.huge
		local closest_node: Node

		for _, node in previous_layer do
			if table.find(nodes, node) then
				continue
			end
			local magnitude = vector.magnitude(position - node.position)

			if magnitude < closest_magnitude then
				closest_magnitude = magnitude
				closest_node = node
			end
		end

		nodes[index] = closest_node
	end
	return nodes
end

local function GET_POSITION_OF_NODE_BELOW(node_cframe: CFrame, node_size: vector): vector
	return (node_cframe * CFrame.new(0, -node_size.y, 0)).Position
end

local FLAT_NODE_CONNECTOR: NodeConnector = function(node_cframe, node_size, layer_below)
	local below_position = GET_POSITION_OF_NODE_BELOW(node_cframe, node_size)
	local nearest_dist = math.huge
	local nearest_node: Node

	for _, node in layer_below do
		local dist = vector.magnitude(below_position - node.position)

		if dist < nearest_dist then
			nearest_dist = dist
			nearest_node = node
		end
	end
	return { nearest_node }
end

local FLAT_TAPER_TRANSFORMER = table.freeze({
	layer_editor = function(bottom_box_cframe, box_size)
		return box_size, bottom_box_cframe
	end,
	node_connector = FLAT_NODE_CONNECTOR,
}) :: TaperTransformer

local TAPER_TRANSFORMERS: { [TaperType]: TaperTransformer } = table.freeze({
	Pyramid = table.freeze({
		node_connector = function(node_cframe, node_size, previous_layer)
			return GET_NODES_CLOSEST_TO_POSITION(
				previous_layer,
				GET_POSITION_OF_NODE_BELOW(node_cframe, node_size),
				4
			)
		end,
		layer_editor = function(bottom_box_cframe, box_size, node_size, index)
			return bottom_box_cframe,
				box_size - vector.create(node_size.x * index, 0, node_size.z * index)
		end,
	}),

	Slope = table.freeze({
		layer_editor = function(bottom_box_cframe, box_size, node_size, index, taper_towards_side, box_size_halved)
			if taper_towards_side == "Front" then
				local layer_cframe = bottom_box_cframe * CFrame.new(0, 0, -box_size_halved.z)

				return layer_cframe * CFrame.new(0, 0, box_size.z / 2),
					box_size - vector.create(0, 0, node_size.z * index)
			elseif taper_towards_side == "Right" then
				local layer_cframe = bottom_box_cframe * CFrame.new(box_size_halved.x, 0, 0)

				return layer_cframe * CFrame.new(-box_size.x / 2, 0, 0),
					box_size - vector.create(node_size.x * index, 0, 0)
			elseif taper_towards_side == "Left" then
				local layer_cframe = bottom_box_cframe * CFrame.new(-box_size_halved.x, 0, 0)

				return layer_cframe * CFrame.new(box_size.x / 2, 0, 0),
					box_size - vector.create(node_size.x * index, 0, 0)
			else -- back
				local layer_cframe = bottom_box_cframe * CFrame.new(0, 0, box_size_halved.z)

				return layer_cframe * CFrame.new(0, 0, -box_size.z / 2),
					box_size - vector.create(0, 0, node_size.z * index)
			end
		end :: any,
		--[[
			slope is basically the same as flat, as in theres always going to be a node directly below any node
			so it just uses the same node connector as flat
		]]
		node_connector = FLAT_NODE_CONNECTOR,
	}),

	Hip = table.freeze({
		node_connector = function(node_cframe, node_size, previous_layer, taper_towards_side)
			local negative_node_size = -node_size
			local approximate_below_cframe_offset = if taper_towards_side == "Right" or taper_towards_side == "Left" then
					CFrame.new(0, negative_node_size.y, negative_node_size.z))
				else
					CFrame.new(negative_node_size.x, negative_node_size.y, 0))

			return GET_NODES_CLOSEST_TO_POSITION(
				previous_layer, (node_cframe * approximate_below_cframe_offset).Position, 2
			)
		end,
		layer_editor = function(bottom_box_cframe, box_size, node_size, index, taper_towards_side)
			local sub_vector = if taper_towards_side == "Front" or taper_towards_side == "Back" then
					vector.create(node_size.x * index, 0, 0)
				else
					-- right and left
					vector.create(0, 0, node_size.z * index)

			return bottom_box_cframe, box_size - sub_vector
		end,
	} :: TaperTransformer),

	Flat = FLAT_TAPER_TRANSFORMER,
}) :: any

local stack_tree = {}

local function get_node_cframe(
	layer_cframe: CFrame, x: number, z: number,
	diff_x: number, diff_z: number,
	node_rotation_offset: CFrame
): CFrame
	return (layer_cframe * CFrame.new(x - diff_x, 0, z - diff_z)) * node_rotation_offset
end

--[[
	returns (layer_cframe, steps, diff_x, diff_z )
]]
local function get_layer_info(
	node_size: vector, half_node_size: vector,
	half_node_height: number, layer_size: vector,
	bottom_box_cframe: CFrame, previous_index: number
): (CFrame, number, number, number, number)
	local offset_cframe = CFrame.new(0, (node_size.y * previous_index) + half_node_height, 0)
	local size_diff = (layer_size / 2) - half_node_size
	local steps = layer_size - node_size

	return bottom_box_cframe * offset_cframe,
		steps.x,
		steps.z,
		size_diff.x,
		size_diff.z
end

local function get_taper_transformer(info: StackFillInfo): TaperTransformer
	local taper_type = info.taper_type

	if taper_type then
		local transformer = TAPER_TRANSFORMERS[taper_type]

		if transformer then
			return transformer
		else
			error(`[STACK TREE]: no taper transformer exists for string '{taper_type}'`)
		end
	else
		return FLAT_TAPER_TRANSFORMER
	end
end

local function get_node_rotation_offset(info: StackFillInfo): CFrame
	local node_rotation_offset = info.node_rotation_offset

	return if node_rotation_offset then
			CFrame.Angles(
				math.rad(node_rotation_offset.x),
				math.rad(node_rotation_offset.y),
				math.rad(node_rotation_offset.z)
			)
		else
			CFrame.identity
end

local function get_padding(info: StackFillInfo): vector
	local padding = info.padding

	return if padding then
			-- Vector2 has lowercase x & y properties, theyre just "deprecated"
			vector.create((padding :: any).x, (padding :: any).y, 0)
		else
			vector.zero
end

function stack_tree.generate(info: StackFillInfo): Tree
	local taper_towards_side = info.face_taper_towards_side or "Back"
	local total_top_layers_to_taper = info.total_top_layers_to_taper
	local node_rotation_offset = get_node_rotation_offset(info)
	local box_cframe = info.box_cframe or CFrame.identity
	local starting_box_size = info.box_size
	local padding = get_padding(info)

	local box_size = starting_box_size - padding
	local node_size = info.node_size + padding

	if node_size.x > box_size.x or node_size.y > box_size.y or node_size.z > box_size.z then
		error("[STACK TREE]: 'node_size' cannot be greater than 'box_size'")
	end

	-- calculating the most amount of nodes that can fit within the given box size
	local nodes_per_side = box_size // node_size

	if nodes_per_side.x < 1 or nodes_per_side.y < 1 or nodes_per_side.z < 1 then
		error("[STACK TREE]: cannot fit a single node into the given 'box_size'")
	end

	if info.max_nodes_xyz then
		nodes_per_side = vector.clamp(nodes_per_side, vector.one, info.max_nodes_xyz)
	end

	box_size = nodes_per_side * node_size

	local bottom_box_cframe = box_cframe * CFrame.new(0, -starting_box_size.y / 2, 0)
	local total_layers = if info.should_stack then nodes_per_side.y else 1
	local half_nodes_size = node_size / 2
	local half_nodes_height = half_nodes_size.y
	local taper_starting_index = 2

	if total_top_layers_to_taper then
		taper_starting_index = total_layers - total_top_layers_to_taper

		if 2 > taper_starting_index then
			error("[STACK TREE]: 'total_layers' - 'total_top_layers_to_taper' is less than 2, cannot start taper below 2")
		end
	end

	local transformer = get_taper_transformer(info)
	local layer_editor = transformer.layer_editor
	local node_connector = FLAT_NODE_CONNECTOR
	local half_box_size = box_size / 2
	local node_size_x = node_size.x
	local node_size_z = node_size.z
	local previous_layer: { Node }
	local nodes = {} :: { Node }
	local node_count = 1

	do
		local layer_cframe, steps_x, steps_z, diff_x, diff_z =	get_layer_info(
			node_size, half_nodes_size, half_nodes_height,
			box_size, bottom_box_cframe, 0
		)
		local layer = table.create(steps_x * steps_z)

		--[[
			using -0.001 as 1 has an offset that doesn't correctly
			center cframes properly, and -0.001 because the
			minimum amount you can move a part/model is by 0.001
		]]
		for x = -0.001, steps_x, node_size_x do
			for z = -0.001, steps_z, node_size_z do
				local node_cframe = get_node_cframe(layer_cframe, x, z, diff_x, diff_z, node_rotation_offset)

				layer[node_count] = {
					position = node_cframe.Position,
					cframe = node_cframe,
					takeable = false,
					taken = true,
				}
				node_count += 1
			end
		end

		previous_layer = layer
	end

	table.move(previous_layer, 1, node_count, 1, nodes)

	if total_layers ~= 1 then
		local previous_layer_index = 1
		local layer_size = box_size

		for layer_index = 2, total_layers do
			local layer_cframe, steps_x, steps_z, diff_x, diff_z = get_layer_info(
				node_size, half_nodes_size, half_nodes_height,
				layer_size, bottom_box_cframe, previous_layer_index
			)
			local layer_node_count = steps_x * steps_z

			if layer_node_count < 1 then
				break
			end

			local layer = table.create(layer_node_count) :: { Node }
			local node_index = 1

			-- cannot do >= because otherwise itll taper for an extra layer
			if layer_index > taper_starting_index then
				layer_cframe, layer_size = layer_editor(
					layer_cframe, layer_size, node_size, previous_layer_index,
					taper_towards_side :: any, half_box_size
				)
				node_connector = transformer.node_connector
			end

			for x = -0.001, steps_x, node_size_x do
				for z = -0.001, steps_z, node_size_z do
					local node_cframe = get_node_cframe(layer_cframe, x, z, diff_x, diff_z, node_rotation_offset)
					local supporters = node_connector(node_cframe, node_size, previous_layer, taper_towards_side :: any)
					local has_one_supporter = not supporters[2]
					local node = {
						supporter = if has_one_supporter then supporters[1] else supporters,
						has_one_supporter = has_one_supporter,
						position = node_cframe.Position,
						cframe = node_cframe,
						takeable = false,
						taken = true,
					}

					for _, supporter_node in supporters do
						local supported = supporter_node.supported

						if supported then
							table.insert(supported, node)
						else
							supporter_node.supported = { node }
						end
					end

					layer[node_index] = node
					node_index += 1
				end
			end

			table.move(layer, 1, layer_node_count, node_count + 1, nodes)
			previous_layer_index = layer_index
			node_count += layer_node_count
			previous_layer = layer
		end
	end

	-- setting all the nodes at the top to be able to be taken
	for _, node in previous_layer do
		node.takeable = true
	end

	if info.randomly_rotate_nodes then
		local random_rotation_min_max = info.random_rotation_min_max
			or DEFUALT_RANDOM_ROTATE_MIN_MAX
		local min = random_rotation_min_max.Min
		local max = random_rotation_min_max.Max

		for _, node in nodes do
			node.cframe *= CFrame.Angles(
				math.rad(random.float(min, max)),
				math.rad(random.float(min, max)),
				math.rad(random.float(min, max))
			)
		end
	end

	return {
		position = (bottom_box_cframe * CFrame.new(0, half_box_size.y, 0)).Position,
		nodes = table.freeze(nodes),
		current_stock = node_count,
		max_stock = node_count,
	}
end

--[[
	Returns the closest empty node within the tree to the position, and the nodes distance from position.
]]
function stack_tree.closest_empty_node(tree: Tree, position: vector): (Node, number)
	local nearest_node_dist = math.huge
	local nearest_node: Node

	for _, node in tree.nodes do
		if node.taken or node.takeable then
			continue
		end
		local supporter = node.supporter

		if supporter then
			if node.has_one_supporter then
				if not supporter.takeable then
					continue
				end
			else
				local are_supporting_nodes_taken = false

				for _, supporting_node in supporter do
					if supporting_node.taken then
						are_supporting_nodes_taken = true
						break
					end
				end

				if not are_supporting_nodes_taken then
					continue
				end
			end
		end
		local dist = vector.magnitude(node.position - position)

		if dist < nearest_node_dist then
			nearest_node_dist = dist
			nearest_node = node
		end
	end

	return nearest_node, nearest_node_dist
end

--[[
	Gives the closest node within the tree to the given position,
	alongside the distance between the node and the given position
]]
function stack_tree.closest_node(tree: Tree, position: vector): (Node, number)
	local nearest_node_dist = math.huge
	local nearest_node: Node

	for _, node in tree.nodes do
		local dist = vector.magnitude(node.position - position)

		if dist < nearest_node_dist then
			nearest_node_dist = dist
			nearest_node = node
		end
	end

	return nearest_node, nearest_node_dist
end

--[[
	Marks the provided node within the node tree as empty
]]
function stack_tree.mark_node_empty(tree: Tree, node: Node)
	local supporter = node.supporter
	node.takeable = false
	node.taken = false

	tree.current_stock -= 1

	if supporter then
		if node.has_one_supporter then
			supporter.takeable = false
		else
			--[[
				checking if the supporting nodes for this node
				have nodes that are taken that they are supporting,
				if they don't have any we set "takeable" to true, otherwise false
			]]
			for _, supporting_node in supporter do
				local can_supporting_node_be_grabbed = true

				for _, node in supporting_node.supported :: { Node } do
					if node.taken then
						can_supporting_node_be_grabbed = false
						break
					end
				end

				supporting_node.takeable = can_supporting_node_be_grabbed
			end
		end
	end
end

--[[
	Marks the provided node within the node tree as taken
]]
function stack_tree.mark_node_taken(tree: Tree, node: Node)
	local supporter = node.supporter
	node.takeable = true
	node.taken = true

	tree.current_stock += 1

	if supporter then
		if node.has_one_supporter then
			supporter.takeable = false
		else
			for _, supporting_node in supporter do
				supporting_node.takeable = false
			end
		end
	end
end

--[[
	Tells if the tree has every single node taken
]]
function stack_tree.is_full(tree: Tree): boolean
	return tree.current_stock == tree.max_stock
end

return table.freeze(stack_tree)