local fs = require("@lune/fs")
local process = require("@lune/process")
local DateTime = require("@lune/datetime")

local frktest = require("../luau_packages/frktest")
local check = frktest.assert.check

local readToEnd = require("../.lune/util/reader_utils").readToEnd
local unzip = require("../lib")

local ZIPS = fs.readDir("tests/data")
local FALLIBLES = {
	"invalid_cde_number_of_files_allocation_greater_offset.zip",
	"invalid_cde_number_of_files_allocation_smaller_offset.zip",
	"invalid_offset.zip",
	"invalid_offset2.zip",
	"invalid_version.zip",
	"misaligned_comment.zip",
	"comment_garbage.zip",
	"chinese.zip", -- FIXME: Support encoding other than UTF-8 and ASCII using OS APIs after FFI
	"max_comment_size.zip", -- NOTE: We test for this in the edge cases suite
}

local METHOD_NAME_TRANSFORMATIONS: { [string]: unzip.CompressionMethod } = {
	["Defl:N"] = "DEFLATE",
	["Stored"] = "STORE",
}

-- Non conclusive translations from host OS zipinfo field and MadeByOS union
local OS_NAME_TRANSFORMATIONS: { [string]: unzip.MadeByOS } = {
	["unx"] = "UNIX",
	["hpf"] = "OS/2",
	["mac"] = "MAC",
	["ntfs"] = "NTFS",
}

local function timestampToValues(dosTimestamp: number): DateTime.DateTimeValues
	local time = bit32.band(dosTimestamp, 0xFFFF)
	local date = bit32.band(bit32.rshift(dosTimestamp, 16), 0xFFFF)

	return {
		year = bit32.band(bit32.rshift(date, 9), 0x7f) + 1980,
		month = bit32.band(bit32.rshift(date, 5), 0x0f),
		day = bit32.band(date, 0x1f),

		hour = bit32.band(bit32.rshift(time, 11), 0x1f),
		minute = bit32.band(bit32.rshift(time, 5), 0x3f),
		second = bit32.band(time, 0x1f) * 2,
	}
end

function dateFuzzyEq(date1: string, date2: string, thresholdDays: number): boolean
	-- Convert the date strings to Lua date tables
	local year1, month1, day1 = date1:match("(%d+)-(%d+)-(%d+)")
	local year2, month2, day2 = date2:match("(%d+)-(%d+)-(%d+)")

	-- Create date tables
	local dt1 =
		os.time({ year = assert(tonumber(year1)), month = assert(tonumber(month1)), day = assert(tonumber(day1)) })
	local dt2 =
		os.time({ year = assert(tonumber(year2)), month = assert(tonumber(month2)), day = assert(tonumber(day2)) })

	-- Calculate the difference in seconds
	local difference = math.abs(dt1 - dt2)

	-- Calculate the threshold in seconds
	local threshold_seconds = thresholdDays * 86400 -- 86400 seconds in a day

	-- Check if the difference is within the threshold
	return difference <= threshold_seconds
end

function timeFuzzyEq(time1: string, time2: string, thresholdSeconds: number): boolean
	-- Convert the time strings to hours, minutes, and seconds
	local hour1, minute1 = time1:match("(%d+):(%d+)")
	local hour2, minute2 = time2:match("(%d+):(%d+)")

	-- Create time tables and convert to seconds
	local totalSeconds1 = (assert(tonumber(hour1)) * 3600) + (assert(tonumber(minute1)) * 60)
	local totalSeconds2 = (assert(tonumber(hour2)) * 3600) + (assert(tonumber(minute2)) * 60)

	-- Calculate the difference in seconds
	local difference = math.abs(totalSeconds1 - totalSeconds2)

	-- Check if the difference is within the threshold
	return difference <= thresholdSeconds
end

return function(test: typeof(frktest.test))
	test.suite("ZIP metadata tests", function()
		for _, file in ZIPS do
			if not string.match(file, "%.zip$") then
				-- Not a zip file, skip
				continue
			end

			local checkErr: ((...any) -> any?) -> nil = if table.find(FALLIBLES, file)
				then check.should_error
				else check.should_not_error

			test.case(`Parsed metadata matches unzip output - {file}`, function()
				checkErr(function()
					file = "tests/data/" .. file
					local data = fs.readFile(file)
					local zip = unzip.load(buffer.fromstring(data))

					-- NOTE: `process.create` instead of `process.exec` so we can continuously stream data and
					-- prevent pipe backpressure, see `readToEnd`
					local unzipResult = process.create("unzip", { "-v", file })
					local unzipOutput = readToEnd(unzipResult.stdout)

					assert(unzipResult:status().ok)

					-- Parse unzip output
					for line in string.gmatch(unzipOutput, "[^\r\n]+") do
						if
							not string.match(line, "^Archive:")
							and not string.match(line, "^%s+Length")
							and not string.match(line, "^%s*%-%-%-%-")
							and not string.match(line, "files?$")
							and #line > 0
						then
							local length, method, size, cmpr, expectedDate, expectedTime, crc32, name = string.match(
								line,
								"^%s*(%d+)%s+(%S+)%s+(%d+)%s+([+-]?%d*%%?)%s+(%d%d%d%d%-%d%d%-%d%d)%s+(%d%d:%d%d)%s+(%x+)%s+(.+)$"
							)

							local entry = assert(zip:findEntry(assert(name)))

							if entry.versionMadeBy.os == "UNIX" then
								check.not_nil(entry:unixMode())
							end

							local ok, zipinfoResult = pcall(process.exec, "zipinfo", { file, name })
							if ok then
								-- Errors can only occur when there is a non utf-8 file name, in which case
								-- we skip that file
								assert(zipinfoResult.ok)
								local versionMadeBySoftware, versionMadeByOS =
									string.match(zipinfoResult.stdout, "^.*%s+(%d+%.%d+)%s+(%S+).*$")

								check.equal(versionMadeBySoftware, entry.versionMadeBy.software)
								check.equal(OS_NAME_TRANSFORMATIONS[assert(versionMadeByOS)], entry.versionMadeBy.os)
							end

							local gotCmpr = entry:compressionEfficiency()
							local gotDateTime = DateTime.fromLocalTime(
								timestampToValues(entry.timestamp) :: DateTime.DateTimeValueArguments
							)

							check.equal(tonumber(length), entry.size)
							check.equal(tonumber(size), entry.compressedSize)

							if gotCmpr ~= nil then
								check.equal(cmpr, gotCmpr .. "%")
							end

							check.equal(METHOD_NAME_TRANSFORMATIONS[method :: string], entry.method)
							check.is_true(
								dateFuzzyEq(gotDateTime:formatLocalTime("%Y-%m-%d"), expectedDate :: string, 1)
							)

							-- TODO: Use extra datetime field
							check.is_true(
								-- Allow a threshold of 26 hours, which is the largest possible gap between any two
								-- timezones
								timeFuzzyEq(gotDateTime:formatLocalTime("%H:%M"), expectedTime :: string, 26 * 3600)
							)

							check.equal(string.format("%08x", entry.crc), crc32)
						end
					end

					return "<test>"
				end)
			end)
		end
	end)
end


local frktest = require("../luau_packages/frktest")
local check = frktest.assert.check

local path = require("../lib/utils/path")

return function(test: typeof(frktest.test))
	test.suite("Path utilities function as expected", function()
		test.case("Canonicalize should handle basic paths", function()
			check.equal(path.canonicalize("foo/bar"), "foo/bar")
			check.equal(path.canonicalize("foo\\bar"), "foo/bar")
		end)

		test.case("Canonicalize should remove current directory markers", function()
			check.equal(path.canonicalize("foo/./bar"), "foo/bar")
			check.equal(path.canonicalize("./foo/bar"), "foo/bar")
		end)

		test.case("Canonicalize should handle parent directory traversal", function()
			check.equal(path.canonicalize("foo/bar/../baz"), "foo/baz")
			check.equal(path.canonicalize("foo/bar/../../baz"), "baz")
		end)

		test.case("isAbsolute should identify Unix-style absolute paths", function()
			check.is_true(path.isAbsolute("/foo/bar"))
			check.is_true(path.isAbsolute("//foo/bar"))
		end)

		test.case("isAbsolute should identify Windows-style absolute paths", function()
			check.is_true(path.isAbsolute("C:\\foo\\bar"))
			check.is_true(path.isAbsolute("\\\\server\\share"))
		end)

		test.case("isAbsolute should identify relative paths", function()
			check.is_false(path.isAbsolute("foo/bar"))
			check.is_false(path.isAbsolute("./foo/bar"))
		end)

		test.case("isRelative should be inverse of isAbsolute", function()
			check.is_false(path.isRelative("/foo/bar"))
			check.is_false(path.isRelative("C:\\foo\\bar"))
			check.is_true(path.isRelative("foo/bar"))
			check.is_true(path.isRelative("./foo/bar"))
		end)

		test.case("isSafe should reject paths with null bytes", function()
			check.is_false(path.isSafe("foo\0bar"))
		end)

		test.case("isSafe should reject absolute paths", function()
			check.is_false(path.isSafe("/foo/bar"))
			check.is_false(path.isSafe("C:\\foo\\bar"))
		end)

		test.case("isSafe should reject paths that escape current directory", function()
			check.is_false(path.isSafe("../foo"))
			check.is_false(path.isSafe("foo/../../bar"))
		end)

		test.case("isSafe should accept safe relative paths", function()
			check.is_true(path.isSafe("foo/bar"))
			check.is_true(path.isSafe("foo/bar/baz"))
		end)

		test.case("Sanitize should remove special components", function()
			check.equal(path.sanitize("../foo/bar"), "foo/bar")
			check.equal(path.sanitize("./foo/bar"), "foo/bar")
			check.equal(path.sanitize("C:\\foo\\bar"), "foo/bar")
			check.equal(path.sanitize("\\\\server\\share\\foo"), "share/foo")
		end)

		test.case("Sanitize should truncate at null bytes", function()
			check.equal(path.sanitize("foo\0bar/baz"), "foo")
		end)

		test.case("Sanitize should convert backslashes to forward slashes", function()
			check.equal(path.sanitize("foo\\bar\\baz"), "foo/bar/baz")
		end)

		test.case("Sanitize should handle empty components", function()
			check.equal(path.sanitize("/foo//bar"), "foo/bar")
		end)
	end)
end


local fs = require("@lune/fs")
local process = require("@lune/process")

local frktest = require("../luau_packages/frktest")
local check = frktest.assert.check

local ZipReader = require("../lib")

local ZIPS = {
	"tests/data/files_and_dirs.zip",
	"tests/data/symlink.zip",
	"tests/data/extended_timestamp.zip",
}

return function(test: typeof(frktest.test))
	test.suite("ZIP walking tests", function()
		test.case("Walks all entries recursively", function()
			for _, file in ZIPS do
				local data = fs.readFile(file)
				local zip = ZipReader.load(buffer.fromstring(data))

				-- Get entries from our implementation
				local entries = {}
				zip:walk(function(entry)
					if entry.name ~= "/" then
						table.insert(entries, entry:getPath())
					end
				end)
				table.sort(entries)

				-- Get entries from unzip command
				local result = process.exec("unzip", { "-l", file })
				check.is_true(result.ok)

				-- Parse unzip output into sorted array
				local unzipEntries = {}
				for line in string.gmatch(result.stdout, "[^\r\n]+") do
					-- Skip header/footer lines and empty lines
					if
						not string.match(line, "^Archive:")
						and not string.match(line, "^%s+Length")
						and not string.match(line, "^%s*%-%-%-%-")
						and not string.match(line, "^%s+%d+%s+%d+ files?$")
						and #line > 0
					then
						-- Extract filename from unzip output format
						local name = string.match(line, "%S+$")
						if name then
							table.insert(unzipEntries, name)
						end
					end
				end
				table.sort(unzipEntries)

				-- Compare results
				check.table.equal(entries, unzipEntries)
			end
		end)

		test.case("Walks with correct depth values", function()
			for _, file in ZIPS do
				local data = fs.readFile(file)
				local zip = ZipReader.load(buffer.fromstring(data))

				-- Verify depth values increase correctly
				local rootSeen = false

				zip:walk(function(entry, depth)
					if entry:getPath() == "/" then
						check.equal(depth, 0)
						rootSeen = true
						return
					end

					-- Count path separators to verify depth, starting at 1 for `/`
					local expectedDepth = 1
					for _ in string.gmatch(entry:getPath():gsub("/$", ""), "/") do
						expectedDepth += 1
					end

					check.equal(depth, expectedDepth)
				end)

				check.is_true(rootSeen)
			end
		end)
	end)
end


export type InstanceProps = {
	kind: nil | {
		kind: "folder",
		realtype: string | nil,
		props: string | nil,
		root: string,
	},
	path: string | nil,
	children: { [string]: InstanceProps },
}
export type Manifest = {
	game: { [string]: any },
}

local module = { }

module.colors = {
	BLUE = "\27[34m",
	YELLOW = "\27[33m",
	LIME = "\27[32m",
	RESET = "\27[0m",
}

module.criticalErrors = {}
function module.log(message: string)
	print(message .. module.colors.RESET)
end
function module.warn(message: unknown, depth: number?)
	module.log(`{module.colors.YELLOW}[WARN] {string.rep("  ", depth or 0)}{message}`)
end
function module.logError(message: string, errorDetail: string?)
	local fullMessage = message .. (errorDetail and ": " .. tostring(errorDetail) or "")
	module.log(fullMessage)
	table.insert(module.criticalErrors, fullMessage)
end

function module.protectedCall<T>(func: (...any) -> T, errorMessage: string, ...): (boolean, T)
	local success, result = pcall(func, ...)
	if not success then
		module.logError(errorMessage .. ": " .. tostring(result))
	end
	return success, result
end

return module


--[[
  Place Builder Script
  Reads a `structure.json` manifest to build a Roblox place file (`.rbxl`).
  This script recursively processes a structure of folders and model files (.rbxm/.rbxmx)
  and assembles them into a new DataModel.
]]

-- Required modules
local fs = require("@lune/fs")
local roblox = require("@lune/roblox")
local process = require("@lune/process")
local serde = require("@lune/serde")
local shared = require("./_shared")

--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------
local CONFIG = {
	ManifestPath = "./lune/structure.json",
	OutputPath = "place.rbxl",
	RojoConfigPath = process.args[1],
}

-- Table for special containers
local SPECIAL_CONTAINERS = {
	StarterPlayerScripts = "StarterPlayerScripts",
	StarterCharacterScripts = "StarterCharacterScripts",
}

--------------------------------------------------------------------------------
-- Utility Functions
--------------------------------------------------------------------------------
local missingFiles = {}
local criticalErrors = shared.criticalErrors

local BLUE = shared.colors.BLUE
local LIME = shared.colors.LIME
local RESET = shared.colors.RESET

--------------------------------------------------------------------------------
-- Core Functions
--------------------------------------------------------------------------------
local function loadAndPlaceModel(filePath: string, parent: roblox.Instance, name: string): { roblox.Instance }
	if not fs.isFile(filePath) then
		table.insert(missingFiles, filePath)
		return {}
	end

	local success, contents = shared.protectedCall(function() return fs.readFile(filePath) end, "Failed to read file: " .. filePath)
	if not success then return {} end

	local success2, localInstances = shared.protectedCall(function() return roblox.deserializeModel(contents) end, "Failed to deserialize: " .. filePath)
	if not success2 then return {} end

	if #localInstances == 0 then
		return {}
	end

	for _, inst in ipairs(localInstances) do
		inst.Name = name
		print("Placing instance " .. BLUE .. inst.Name .. RESET)
		print("  from " .. LIME .. filePath .. RESET)
		print("  into " .. LIME .. (if parent:IsA('DataModel') then "game" else parent:GetFullName()) .. RESET .. "..")
		local success3 = shared.protectedCall(function() inst.Parent = parent end, "Failed to parent instance '" .. inst.Name .. "' from " .. filePath .. " to " .. parent:GetFullName())
		if not success3 then
			return {}
		end
	end

	return localInstances
end

local function createOrFindInstance(name: string, parent: roblox.Instance, className: string?): roblox.Instance
	local instance = parent:FindFirstChild(name)
	if not instance then
		local actualClassName = SPECIAL_CONTAINERS[name] or className or "Folder"
		print("Creating instance: " .. BLUE .. name .. RESET .. " of class " .. BLUE .. actualClassName .. RESET .. " in " .. LIME .. parent:GetFullName() .. RESET)
		instance = roblox.Instance.new(actualClassName)
		instance.Name = name
		instance.Parent = parent
	else
		print("Found existing container: " .. LIME .. instance:GetFullName() .. RESET .. " of class " .. BLUE .. instance.ClassName .. RESET)
	end
	return instance
end

local function processFolder(rootPath: string, parent: roblox.Instance)
	if not fs.isDir(rootPath) then fs.writeDir(rootPath) end

	local files = fs.readDir(rootPath)
	for _, fname in files do
		local fpath = rootPath .. '/' .. fname
		local baseName = fname:match("(.+)%..+") or fname

		if fs.isFile(fpath) then
			loadAndPlaceModel(fpath, parent, baseName)
		else
			local instance = createOrFindInstance(baseName, parent)
			processFolder(fpath, instance)
		end
	end
end

local function processNode(props: shared.InstanceProps, name: string, parent: roblox.Instance): boolean
	if props.kind and props.kind.kind == "folder" then
		local instance: roblox.Instance
		if props.kind.props then
			instance = loadAndPlaceModel(props.kind.props, parent, name)[1]
		else
			instance = createOrFindInstance(name, parent, props.kind.realtype)
		end

		processFolder(props.kind.root, instance)
		return true
	end

	local instance: roblox.Instance
	if props.path then
		local models = loadAndPlaceModel(props.path, parent, name)
		instance = models[1]
		if not instance then return false end
	else
		if parent:IsA('DataModel') then
			instance = (parent :: roblox.DataModel):GetService(name)
		else
			instance = createOrFindInstance(name, parent)
		end
	end

	if props.children then
		local allChildrenProcessed = true
		for childName, childProps in pairs(props.children) do
			if not processNode(childProps, childName, instance) then
				allChildrenProcessed = false
			end
		end
		return allChildrenProcessed
	end

	return true
end

local function processStructureJson(dataModel: roblox.DataModel): boolean
	print("Reading manifest from: " .. LIME .. CONFIG.ManifestPath .. RESET)
	local success, manifestJson = shared.protectedCall(function() return fs.readFile(CONFIG.ManifestPath) end, "Failed to read manifest file '" .. CONFIG.ManifestPath .. "'")
	if not success then return false end

	local success2, manifest: shared.Manifest = shared.protectedCall(function() return serde.decode("json", manifestJson) end, "Failed to parse manifest JSON")
	if not success2 then return false end
	print(LIME .. "Manifest parsed successfully." .. RESET)

	for name, props in pairs(manifest.game) do
		processNode(props, name, dataModel)
	end

	print("Finished processing all services")
	return true
end

--------------------------------------------------------------------------------
-- Rojo Integration Functions
--------------------------------------------------------------------------------
local function getScriptType(file: string): string
	if file:match("%.server%.lua[u]?$") then return "Script"
	elseif file:match("%.client%.lua[u]?$") then return "LocalScript"
	else return "ModuleScript" end
end

local function insertFromPath(path: string, instance: roblox.Instance, level: number?)
	local levelNum = level or 0
	if not fs.isDir(path) then return end
	local items = fs.readDir(path)
	for _, file in items do
		local full = path .. "/" .. file
		if fs.isFile(full) then
			if file:match("%.lua[u]?$") and file ~= "init.lua" and file ~= "init.luau" then
				local script = roblox.Instance.new(getScriptType(file))
				script.Name = file:gsub("%.lua[u]?$", "")
				script.Source = fs.readFile(full)
				script.Parent = instance
				print(string.rep("  ", levelNum) .. "├── " .. BLUE .. script.ClassName .. RESET .. ": " .. LIME .. script:GetFullName() .. RESET)
			elseif file:match("%.rbxm?$") then
				local instances = roblox.deserializeModel(fs.readFile(full))
				for _, inst in instances do inst.Parent = instance end
				print(string.rep("  ", levelNum) .. "├── Inserted model: " .. LIME .. file .. RESET)
			end
		elseif fs.isDir(full) then
			local initPath = full .. "/init.lua"
			if not fs.isFile(initPath) then initPath = full .. "/init.luau" end
			if fs.isFile(initPath) then
				local script = roblox.Instance.new("ModuleScript")
				script.Name = file
				script.Source = fs.readFile(initPath)
				script.Parent = instance
				print(string.rep("  ", levelNum) .. "├── " .. BLUE .. "ModuleScript" .. RESET .. " from init: " .. LIME .. script:GetFullName() .. RESET)
				insertFromPath(full, script, levelNum + 1)
			else
				local folder = roblox.Instance.new("Folder")
				folder.Name = file
				folder.Parent = instance
				print(string.rep("  ", levelNum) .. "├── " .. BLUE .. "Folder" .. RESET .. ": " .. LIME .. folder:GetFullName() .. RESET)
				insertFromPath(full, folder, levelNum + 1)
			end
		end
	end
end

local function processTree(tree: any, currentInstance: roblox.Instance)
	local pathValue: string?
	for key, value in tree do
		if key == "$path" then
			pathValue = value
		elseif key ~= "$className" then
			if key == "$properties" then
				for prop, val in value do
					currentInstance[prop] = val
				end
			elseif type(value) == "table" then
				local child = currentInstance:FindFirstChild(key) or roblox.Instance.new(value["$className"] or "Folder")
				child.Name = key
				child.Parent = currentInstance
				print("Processed: " .. LIME .. key .. RESET)
				processTree(value, child)
			end
		end
	end
	if pathValue then
		-- pretty stupid fix for relative paths, required for building from nested rojo projects, specifically when using ../../ to go up directories
		local adjustedPath = if pathValue:sub(1, 6) == "../../" then pathValue:sub(7) else pathValue
		if currentInstance.Name ~= "rbxts_include" then
			for _, child in currentInstance:GetChildren() do
				child.Parent = nil
			end
		end
		print("Inserting from: " .. LIME .. adjustedPath .. RESET)
		insertFromPath(adjustedPath, currentInstance, 0)
	end
end

local function integrateRojoScripts(dataModel: roblox.DataModel)
	if not CONFIG.RojoConfigPath then
		print("No Rojo config path provided, skipping Rojo integration.")
		return
	end

	print("Loading rojo config from: " .. LIME .. CONFIG.RojoConfigPath .. RESET)
	local success, rojoConfig = shared.protectedCall(function() return fs.readFile(CONFIG.RojoConfigPath) end, "Failed to read rojo config")
	if not success then return end

	local success2, rojo = shared.protectedCall(function() return serde.decode("json", rojoConfig) end, "Failed to parse rojo config")
	if not success2 then return end
	print(LIME .. "Rojo config loaded." .. RESET)

	print("Processing rojo tree...")
	processTree(rojo.tree, dataModel)
end

--------------------------------------------------------------------------------
-- Main Function
--------------------------------------------------------------------------------
local function main(): boolean
	print("Starting place build process...")
	local dataModel = roblox.Instance.new("DataModel") :: roblox.DataModel

	print("Processing manifest...")
	local success = processStructureJson(dataModel)
	if not success then return false end

	-- Required for database HTTP requests
	dataModel:GetService("HttpService").HttpEnabled = true

	-- Set up default camera
	print("Updating default camera position...")
	local camera = dataModel.Workspace.CurrentCamera
	if camera then
		camera.Name = "Camera"
		local robloxModule = roblox
		local CFrame = robloxModule.CFrame
		local Vector3 = robloxModule.Vector3
		local fromOrientation = CFrame.fromOrientation
		local rad = math.rad
		camera.Focus = CFrame.new(Vector3.new(104.5, -16379.115, 64))
		camera.CFrame = CFrame.new(Vector3.new(165.52, -16352.801, 108.668)) * fromOrientation(rad(-19.818), rad(52.103), -0)
	end

	print("Serializing the place...")
	local success2, output = shared.protectedCall(function() return roblox.serializePlace(dataModel) end, "Failed to serialize place")
	if not success2 then return false end
	print("Writing place to: " .. LIME .. CONFIG.OutputPath .. RESET)
	local success3 = shared.protectedCall(function() fs.writeFile(CONFIG.OutputPath, output) end, "Failed to write place.rbxl")
	if not success3 then return false end
	print("Place saved successfully to " .. LIME .. CONFIG.OutputPath .. RESET)

	integrateRojoScripts(dataModel)

	if CONFIG.RojoConfigPath then
		print("Serializing place after rojo integration...")
		local success4, serialized = shared.protectedCall(function() return roblox.serializePlace(dataModel) end, "Failed to serialize place after rojo")
		if not success4 then return false end
		local success5 = shared.protectedCall(function() fs.writeFile(CONFIG.OutputPath, serialized) end, "Failed to write updated place.rbxl")
		if not success5 then return false end
		print(LIME .. "Place updated!" .. RESET)
	end

	return #criticalErrors == 0
end

--------------------------------------------------------------------------------
-- Script Execution and Final Report
--------------------------------------------------------------------------------
print("--- Starting Build ---")
local buildSuccessful = shared.protectedCall(main, "Main build function failed unexpectedly")

print("\n--- Build Summary ---")
if #missingFiles > 0 then
	print("The following files were missing (and skipped):")
	for _, file in ipairs(missingFiles) do
		print("  - " .. file)
	end
else
	print(LIME .. "No missing files detected." .. RESET)
end

if #criticalErrors > 0 then
	print("\nCritical errors occurred during build:")
	for i, err in ipairs(criticalErrors) do
		print(string.format("  %d. %s", i, err))
	end
end

if buildSuccessful and #criticalErrors == 0 then
	print("\n" .. LIME .. "Build completed successfully!" .. RESET)
elseif not buildSuccessful then
	print("\nBuild failed due to critical errors.")
else
	print("\nBuild completed with warnings (e.g., missing files, but no critical failures).")
end
print("--- End of Build Summary ---")

if not buildSuccessful or #criticalErrors > 0 then
	((os :: unknown) :: { exit: (number) -> () }).exit(1)
end


--[[
  Place Extractor Script
  Reads a Roblox place file (`.rbxl`) and a `structure.json` manifest.
  It finds the instances specified in the manifest within the place file
  and serializes them back into individual .rbxm/.rbxmx model files.
]]

-- Required modules
local fs = require("@lune/fs")
local roblox = require("@lune/roblox")
local serde = require("@lune/serde")
local shared = require("./_shared")

--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------
local CONFIG = {
	-- Enable/disable debug messages
	Debug = true,

	-- Path to the structure manifest file
	ManifestPath = "./lune/structure.json",

	-- Path to the input .rbxl file from which models will be extracted
	InputPath = "place.rbxl",
}

--------------------------------------------------------------------------------
-- State and Helper Functions
--------------------------------------------------------------------------------
local missingInstances = {}
local criticalErrors = shared.criticalErrors

local function debugLog(message, depth)
	if CONFIG.Debug then
		shared.log(`[DEBUG] {string.rep("  ", depth or 0)}{message}`)
	end
end

local function log(message: string, depth: number?)
	shared.log(`{string.rep("  ", depth or 0)}{message}`)
end

-- Utility to create the directory in which the file should reside
local function ensureDirectoryExists(filePath)
	-- Extract the directory path from the full file path
	local dirPath = filePath:match("(.+)/")
	if dirPath then
		-- fs.writeDir creates the directory if it does not exist, and does nothing if it already exists.
		fs.writeDir(dirPath)
	end
end

-- Saves a single Instance to a file, automatically determining the format (binary or XML)
local function saveInstanceToFile(instance, filePath, depth)
	-- Check the file extension to determine if XML format should be used
	local isXml = filePath:lower():match("%.rbxmx$")

	if isXml then
		debugLog(`Serializing {shared.colors.BLUE}{instance:GetFullName()}{shared.colors.RESET} to {shared.colors.LIME}{filePath}{shared.colors.RESET} (XML format)`, depth)
	else
		debugLog(`Serializing {shared.colors.BLUE}{instance:GetFullName()}{shared.colors.RESET} to {shared.colors.LIME}{filePath}{shared.colors.RESET} (binary format)`, depth)
	end

	ensureDirectoryExists(filePath)

	if instance:IsA("Model") and instance.PrimaryPart then
		instance.WorldPivotData = nil
	end

	for _, child in ipairs(instance:GetDescendants()) do
		if child:IsA("Model") and child.PrimaryPart then
			child.WorldPivotData = nil
		end
	end

	-- roblox.serializeModel expects a table of instances.
	-- If isXml is true, XML format will be used, otherwise binary.
	local success, data = shared.protectedCall(
		roblox.serializeModel,
		"Failed to serialize instance " .. instance:GetFullName(),
		{ instance },
		isXml
	)
	if not success then return false end

	success, _ = shared.protectedCall(fs.writeFile, "Failed to write file " .. filePath, filePath, data)
	return success
end

type InstanceProps = shared.InstanceProps
type Manifest = shared.Manifest;

local function saveProps(instance: Instance, filePath: string, depth: number)
	local clone = instance:Clone();
	clone:ClearAllChildren()
	saveInstanceToFile(clone, filePath, depth + 1)
	clone:Destroy()
end

local function processNode(name: string, node: InstanceProps, parentInstance: roblox.Instance, depth: number)
	debugLog(`Looking for instance {shared.colors.BLUE}{name}{shared.colors.RESET} in {shared.colors.LIME}{parentInstance:GetFullName()}`, depth)

	local childInstance;
	if parentInstance:IsA("DataModel") then
		childInstance = (parentInstance :: roblox.DataModel):GetService(name)
	else
		childInstance = parentInstance:FindFirstChild(name)
	end

	if not childInstance then
		local fullPath = parentInstance:GetFullName() .. "." .. name
		shared.warn(`Could not find instance {fullPath}`, depth)
		table.insert(missingInstances, fullPath)
		return
	end

	if node.kind then
		if node.kind.kind == 'folder' then
			if fs.isDir(node.kind.root) then
				fs.removeDir(node.kind.root)
			end

			local root = #childInstance:GetFullName() + 1;
			local function processFoldered(parentInstance: roblox.Instance, depth: number)
				if parentInstance:IsA("Folder") or (node.kind.realtype and parentInstance:IsA(node.kind.realtype)) then
					for i, v in parentInstance:GetChildren() do
						processFoldered(v, depth + 1)
					end
				else
					saveInstanceToFile(parentInstance, node.kind.root .. '/' .. parentInstance:GetFullName():sub(root):gsub("%.", "/") .. '.rbxmx', depth + 1)
				end
			end

			processFoldered(childInstance, depth)

			if node.kind.props then
				saveProps(childInstance, node.kind.props, depth)
			end
			return
		end
	end

	if node.path then
		-- if has both path and children, save only properties
		if node.children then
			saveProps(childInstance, node.path, depth)
		else
			saveInstanceToFile(childInstance, node.path, depth + 1)
		end
	end
	if node.children then
		for k, v in pairs(node.children) do
			processNode(k, v, childInstance, depth + 1)
		end
	end
end

local function main()
	debugLog("Starting place extraction process...")

	-- 1. Reading and parsing the manifest
	debugLog("Reading manifest from: " .. CONFIG.ManifestPath)
	local manifestJson, readErr = fs.readFile(CONFIG.ManifestPath)
	if not manifestJson then
		shared.logError("Failed to read manifest file '" .. CONFIG.ManifestPath .. "': " .. tostring(readErr))
		return false
	end

	local success, manifest: Manifest = shared.protectedCall(serde.decode, "Failed to parse manifest JSON", "json", manifestJson)
	if not success then return false end
	debugLog("Manifest parsed successfully.")

	-- 2. Reading and deserializing the .rbxl file
	debugLog("Reading place file from: " .. CONFIG.InputPath)
	local placeData, readErr2 = fs.readFile(CONFIG.InputPath)
	if not placeData then
		shared.logError("Failed to read place file '" .. CONFIG.InputPath .. "': " .. tostring(readErr2))
		return false
	end

	local success2, dataModel = shared.protectedCall(roblox.deserializePlace, "Failed to deserialize place file", placeData)
	if not success2 then return false end
	debugLog("Place file deserialized successfully.")

	-- 3. Traversing the manifest and exporting models
	debugLog("Starting instance export based on manifest...")
	for name, props in pairs(manifest.game) do
		processNode(name, props, dataModel, 0)
	end

	return #criticalErrors == 0
end

--------------------------------------------------------------------------------
-- Script Execution and Final Report
--------------------------------------------------------------------------------
log("--- Starting Extraction ---")
local extractionSuccessful = shared.protectedCall(main, "Main extraction function failed unexpectedly")

log("\n--- Extraction Summary ---")
if #missingInstances > 0 then
	log(`{shared.colors.RED}The following instances were NOT found in the place file (and were not exported):`)
	for _, path in ipairs(missingInstances) do
		log(`{shared.colors.RED}  - {path}`)
	end
else
	log(`{shared.colors.LIME}All instances from the manifest were found in the place file.`)
end

if #criticalErrors > 0 then
	log(`\n{shared.colors.RED}Critical errors occurred during extraction:`)
	for i, err in ipairs(criticalErrors) do
		log(shared.colors.LIME .. string.format("  %d. %s", i, err))
	end
end

if extractionSuccessful and #criticalErrors == 0 then
	if #missingInstances > 0 then
		log(`\n{shared.colors.YELLOW}Extraction completed with warnings (some instances were missing).`)
	else
		log(`\n{shared.colors.LIME}Extraction completed successfully!`)
	end
else
	log(`\n{shared.colors.RED}Extraction failed due to critical errors.`)
end
log("--- End of Extraction Summary ---")


import { CollectionService } from "@rbxts/services";
import { HostedService } from "engine/shared/di/HostedService";
import { Instances } from "engine/shared/fixes/Instances";
import type { Theme, ThemeKey } from "client/Theme";

@injectable
export class ThemeAutoSetter extends HostedService {
	constructor(@inject theme: Theme) {
		super();

		for (const instance of CollectionService.GetTagged("THEME_SETTER")) {
			const key = instance.GetAttribute("key") as keyof Instance;
			const value = instance.GetAttribute("value") as ThemeKey;

			$log("Auto theming", Instances.pathOf(instance.Parent!), key, value);

			(instance.Parent as Writable<Instance>)[key] = theme.get(value).get() as never;
			instance.Destroy();
		}
	}
}


import { Workspace } from "@rbxts/services";
import { Component } from "engine/shared/component/Component";
import { ComponentInstance } from "engine/shared/component/ComponentInstance";
import { ReplicatedAssets } from "shared/ReplicatedAssets";
import type { BlockLogicTickContext, GenericBlockLogic } from "shared/blockLogic/BlockLogic";
import type { BlockLogicRunner } from "shared/blockLogic/BlockLogicRunner";

export class LogicVisualizer extends Component {
	constructor(runner: BlockLogicRunner, blocks: readonly GenericBlockLogic[]) {
		super();

		const parent = new Instance("Folder", Workspace);
		parent.Name = "LogicVisualizer";
		ComponentInstance.init(this, parent);

		type label = BillboardGui & { readonly Label: TextLabel };
		const labelMap = new Map<GenericBlockLogic, label>();

		const labelTemplate = this.asTemplate(ReplicatedAssets.waitForAsset<label>("Wires", "MarkerValue"), false);

		const setLabelsEnabled = (enabled: boolean) => {
			for (const [, label] of labelMap) {
				label.Enabled = enabled;
			}
		};
		this.onEnable(() => setLabelsEnabled(true));
		this.onEnable(() => setLabelsEnabled(false));

		const tick = (ctx: BlockLogicTickContext) => {
			for (const block of blocks) {
				const label = labelMap.getOrSet(block, () => {
					const label = labelTemplate();
					label.AlwaysOnTop = true;
					label.Name = block.instance!.Name;
					label.Adornee = block.instance!;
					label.Parent = parent;

					return label;
				});

				const info = block.getDebugInfo(ctx);
				label.Label.Text = info.join("\n");
			}
		};
		this.event.subscribeRegistration(() => runner.onAfterTick(tick));
		this.onEnable(() => tick(runner.getContext(false)));
	}
}


import { RunService, TextChatService } from "@rbxts/services";
import { CheckBoxControl } from "client/gui/controls/CheckBoxControl";
import { Control } from "engine/client/gui/Control";
import { Interface } from "engine/client/gui/Interface";
import { PartialControl } from "engine/client/gui/PartialControl";
import { Component } from "engine/shared/component/Component";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { Transforms } from "engine/shared/component/Transforms";
import { HostedService } from "engine/shared/di/HostedService";
import { ObservableValue } from "engine/shared/event/ObservableValue";
import { Strings } from "engine/shared/fixes/String.propmacro";
import { CustomRemotes } from "shared/Remotes";
import { ReplicatedAssets } from "shared/ReplicatedAssets";
import type { CheckBoxControlDefinition } from "client/gui/controls/CheckBoxControl";
import type { PlayerDataStorage } from "client/PlayerDataStorage";
import type { ReadonlyObservableValue } from "engine/shared/event/ObservableValue";
import type { baseAchievementStats } from "server/Achievement";
import type { AchievementData } from "shared/AchievementData";

const defaultAchievementId = 18887954146;

type AchievementNotificationParts = {
	readonly NameLabel: TextLabel;
	readonly DescriptionLabel: TextLabel;
	readonly IconImage: ImageLabel;
	readonly RareGradient: UIGradient;
};
class AchievementNotification extends PartialControl<AchievementNotificationParts> {
	constructor(gui: GuiObject, info: baseAchievementStats) {
		super(gui);

		this.parts.NameLabel.Text = info.name;
		this.parts.DescriptionLabel.Text = info.description;
		this.parts.IconImage.Image = `rbxassetid://${info.imageID ?? defaultAchievementId}`;

		if (info.hidden) {
			this.parts.RareGradient.Enabled = true;
			this.gui.BackgroundColor3 = Color3.fromRGB(129, 0, 129);
		} else {
			this.parts.RareGradient.Enabled = false;
			this.gui.BackgroundColor3 = Color3.fromRGB(1, 4, 9);
		}
	}
}
class NotificationController extends Component {
	constructor(
		achievements: ReadonlyObservableValue<{
			readonly order: readonly string[];
			readonly data: { readonly [x: string]: baseAchievementStats };
		}>,
	) {
		super();

		const template = this.asTemplate(
			Interface.getInterface<{ Floating: { AchievementNotification: GuiObject } }>().Floating
				.AchievementNotification,
		);

		const parent = this.parent(new InstanceComponent(Interface.getInterface<{ Floating: GuiObject }>().Floating));

		let isShowing = false;
		const show = (info: baseAchievementStats) => {
			if (isShowing) {
				task.spawn(() => {
					while (isShowing) {
						task.wait();
					}

					show(info);
				});
				return;
			}

			const notif = parent.parent(new AchievementNotification(template(), info));
			notif.setVisibleAndEnabled(true);

			isShowing = true;
			Transforms.create() //
				.transform(notif.instance, "AnchorPoint", new Vector2(0, 0))
				.move(notif.instance, new UDim2(1, 20, 0, 180))
				.then()
				.transform(notif.instance, "AnchorPoint", new Vector2(1, 0), { duration: 1, style: "Exponential" })
				.move(notif.instance, new UDim2(1, -20, 0, 180), { duration: 1, style: "Exponential" })
				.then()
				.wait(5)
				.then()
				.transform(notif.instance, "AnchorPoint", new Vector2(0, 0), {
					duration: 1,
					direction: "In",
					style: "Exponential",
				})
				.move(notif.instance, new UDim2(1, 20, 0, 180), { duration: 1, direction: "In", style: "Exponential" })
				.then()
				.destroy(notif.instance)
				.func(() => (isShowing = false))
				.run(notif);
		};

		this.event.subscribe(CustomRemotes.achievements.update.invoked, (datas) => {
			for (const [k, v] of pairs(datas)) {
				if (!v.completed) continue;

				const info = achievements.get().data[k];
				if (!info) continue;

				show(info);
			}
		});
	}
}

type AchievementControlParts = {
	readonly TitleLabel: TextLabel;
	readonly DescriptionLabel: TextLabel;
	readonly ProgressButton: TextButton;
	readonly IconImage: ImageLabel;

	readonly ProgressBar: GuiObject & {
		readonly Fill: GuiObject;
		readonly ValueLabel: TextLabel;
	};
};
class AchievementControl extends PartialControl<AchievementControlParts> {
	constructor(
		gui: GuiObject,
		readonly info: baseAchievementStats,
	) {
		super(gui);

		this.parts.TitleLabel.Text = info.name;
		this.parts.DescriptionLabel.Text = info.description;
		this.parts.IconImage.Image = `rbxassetid://${info.imageID ?? defaultAchievementId}`;

		this.update({});

		this.event.loop(1, () => {
			if (!this.data) return;
			this.update(this.data);
		});
	}

	private data?: AchievementData;
	getCachedData() {
		return this.data;
	}
	update(data: AchievementData) {
		this.data = data;
		const showHiddenInStudio = true;
		if (this.info.hidden) {
			const showInStudio = showHiddenInStudio && RunService.IsStudio();
			this.visibilityComponent().setVisible(showInStudio || (data.completed ?? false), "hiddenach");
		} else this.visibilityComponent().setVisible(true);

		const visualizeText = (num: number): string => {
			if (!this.info.units) {
				return Strings.prettyKMT(num);
			}
			if (this.info.units === "time") {
				return Strings.prettyTime(num);
			}
			if (this.info.units === "precise") {
				return tostring(math.round((data.progress ?? 0) * 100) / 100);
			}

			this.info.units satisfies never;
			throw "what";
		};

		if (data.completed && data.completionDateUnix) {
			let completionAgo = DateTime.fromUnixTimestamp(data.completionDateUnix).ToIsoDate();
			completionAgo = Strings.prettySecondsAgo(DateTime.now().UnixTimestamp - data.completionDateUnix);

			this.parts.ProgressButton.Text = `COMPLETED\n${completionAgo}`;
			this.parts.ProgressButton.BackgroundColor3 = Color3.fromRGB(30, 90, 44);
		} else {
			if (this.info.max) {
				this.parts.ProgressButton.Text = `${visualizeText(data.progress ?? 0)}/${visualizeText(this.info.max)}`;
			} else {
				this.parts.ProgressButton.Text = "NOT COMPLETED";
			}

			if (data.progress && data.progress > 0) {
				this.parts.ProgressButton.BackgroundColor3 = Color3.fromRGB(50, 50, 120);
			} else {
				this.parts.ProgressButton.BackgroundColor3 = Color3.fromRGB(90, 90, 90);
			}
		}

		this.parts.ProgressBar.Visible = this.info.max !== undefined;
		if (this.info.max) {
			let progress = (data.progress ?? 0) / this.info.max;
			if (data.completed) {
				progress = 1;
			}

			this.parts.ProgressBar.Fill.Size = new UDim2(progress, 0, 1, 0);
			this.parts.ProgressBar.ValueLabel.Text = `${math.round(progress * 100 * 100) / 100}%`;
		}
	}
}

export type AchievementsGuiParts = {
	readonly Settings: GuiObject;
	readonly TemplatePB: GuiObject;
	readonly ScrollingFrame: ScrollingFrame;
	readonly TotalProgressBar: GuiObject & {
		readonly Fill: GuiObject;
		readonly ValueLabel: TextLabel;
	};
};
@injectable
export class AchievementsGui extends PartialControl<AchievementsGuiParts> {
	constructor(
		gui: GuiObject,
		@inject playerData: PlayerDataStorage,
		@inject achievementController: AchievementController,
	) {
		super(gui);

		type settings = {
			readonly CompletedCheckbox: CheckBoxControlDefinition;
			readonly NoncompletedCheckbox: CheckBoxControlDefinition;
		};
		const settings = this.parent(new PartialControl<settings>(this.parts.Settings));
		const completedcb = this.parent(new CheckBoxControl(settings.parts.CompletedCheckbox));
		completedcb.value.set(true);
		const noncompletedcb = this.parent(new CheckBoxControl(settings.parts.NoncompletedCheckbox));
		noncompletedcb.value.set(true);

		const template = this.asTemplate(this.parts.TemplatePB);

		const achs: { [k in string]: AchievementControl } = {};
		const as = achievementController.allAchievements.get();

		const orderMap = as.order.mapToMap((v, i) => $tuple(v, i));
		const sortedOrder = [...as.order].sort((l, r) => {
			const lc = playerData.achievements.get()?.[l]?.completed ?? false;
			const rc = playerData.achievements.get()?.[r]?.completed ?? false;

			if (lc && !rc) return false;
			if (rc && !lc) return true;

			return (orderMap.get(l) ?? 0) < (orderMap.get(r) ?? 1);
		});
		const sf = this.parent(new Control(this.parts.ScrollingFrame));
		for (const id of sortedOrder) {
			achs[id] = sf.parent(new AchievementControl(template(), as.data[id]));
		}

		const updateFilters = () => {
			const completed = completedcb.value.get() ?? false;
			const noncompleted = noncompletedcb.value.get() ?? false;

			for (const [, ach] of pairs(achs)) {
				const achcompleted = ach.getCachedData()?.completed;
				if (achcompleted) {
					ach.visibilityComponent().setVisible(completed, "filter");
				} else {
					ach.visibilityComponent().setVisible(noncompleted, "filter");
				}
			}
		};
		completedcb.value.subscribe(updateFilters);
		noncompletedcb.value.subscribe(updateFilters);

		this.event.subscribeObservable(
			playerData.achievements,
			(datas) => {
				let completed = 0;
				for (const [id, data] of pairs(datas)) {
					if (!(id in achs)) continue;
					achs[id].update(data);

					if (data.completed) {
						completed++;
					}
				}

				const total = as.order.size();
				this.parts.TotalProgressBar.Fill.Size = new UDim2(completed / total, 0, 1, 0);
				this.parts.TotalProgressBar.ValueLabel.Text = `Achievement completion: ${completed}/${total}`;
			},
			true,
		);
	}
}

export class AchievementController extends HostedService {
	readonly allAchievements;

	constructor() {
		super();

		this.allAchievements = new ObservableValue<{
			readonly order: readonly string[];
			readonly data: { readonly [x: string]: baseAchievementStats };
		}>({ order: [], data: {} });
		this.event.subscribe(CustomRemotes.achievements.loaded.invoked, (data) => this.allAchievements.set(data));

		this.parent(new NotificationController(this.allAchievements));

		const hiddenAchSound = ReplicatedAssets.waitForAsset<Sound>("Effects", "AchievementUnlock", "Hidden");
		const commonAchSound = ReplicatedAssets.waitForAsset<Sound>("Effects", "AchievementUnlock", "Common");

		CustomRemotes.achievements.ahievementUnlock.invoked.Connect(({ player, id }) => {
			const channel = TextChatService.FindFirstChild("TextChannels")?.FindFirstChild("RBXGeneral") as TextChannel;
			const { hidden, name } = this.allAchievements.get().data[id];

			if (hidden) {
				channel?.DisplaySystemMessage(
					`<b><font color='#FF0000'>${player.DisplayName}</font> obtained secret achievement <font color='#B88FFF'>"${name}"</font>!</b>`,
				);
				hiddenAchSound.TimePosition = 0;
				hiddenAchSound.Play();
			} else {
				channel?.DisplaySystemMessage(
					`<font color='#FF0000'>${player.DisplayName}</font> unlocked achievement <font color='#FFD700'>"${name}"</font>!`,
				);
				commonAchSound.TimePosition = 0;
				commonAchSound.Play();
			}
		});
	}
}


import { Workspace } from "@rbxts/services";
import { Beacon } from "client/gui/Beacon";
import { LocalPlayer } from "engine/client/LocalPlayer";
import { Component } from "engine/shared/component/Component";
import { ComponentKeyedChildren } from "engine/shared/component/ComponentKeyedChildren";
import { HostedService } from "engine/shared/di/HostedService";
import { PlayerWatcher } from "engine/shared/PlayerWatcher";
import type { PlayerDataStorage } from "client/PlayerDataStorage";
import type { ReadonlyPlot } from "shared/building/ReadonlyPlot";

@injectable
export class BeaconController extends HostedService {
	constructor(@inject plot: ReadonlyPlot, @inject playerData: PlayerDataStorage) {
		super();

		const plotBeacon = this.initializePlotBeacon(plot);
		const playerBeacons = this.initializePlayerBeacons();

		this.event.subscribeObservable(
			playerData.config.createBased((c) => c.beacons),
			(beacons) => plotBeacon.setEnabled(beacons.plot),
			true,
		);

		this.event.subscribeObservable(
			playerData.config.createBased((c) => c.beacons),
			(beacons) => playerBeacons.setEnabled(beacons.players),
			true,
		);
	}

	private initializePlotBeacon(plot: ReadonlyPlot): Beacon {
		const part = new Instance("Part");
		part.Name = "LocalPlotBeacon";
		part.Anchored = true;
		part.Transparency = 1;
		part.CanQuery = part.CanTouch = part.CanCollide = false;
		part.PivotTo(plot.origin);
		part.Parent = Workspace;

		return this.parent(new Beacon(part, "Plot"));
	}

	private initializePlayerBeacons(): Component {
		const component = new Component();
		const playerBeacons = component.parent(new ComponentKeyedChildren<number, Beacon>());

		const createPlayerBeacon = (player: Player) => {
			// spawn() just in case of infinite wait for something
			spawn(() => {
				while (!player.Character) {
					task.wait(0.1);
				}

				const humanoid = player.Character.WaitForChild("Humanoid") as Humanoid;
				while (!humanoid.RootPart) {
					task.wait(0.1);
				}

				playerBeacons.add(player.UserId, new Beacon(humanoid.RootPart, player.DisplayName));
			});
		};

		const initPlayer = (player: Player) => {
			if (player === LocalPlayer.player) return;
			if (player.Character) createPlayerBeacon(player);

			player.CharacterAdded.Connect(() => createPlayerBeacon(player));
			player.CharacterRemoving.Connect(() => playerBeacons.remove(player.UserId));
		};
		this.event.subscribeCollectionAdded(PlayerWatcher.players, initPlayer, true);

		return component;
	}
}


import { Lighting } from "@rbxts/services";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { Transforms } from "engine/shared/component/Transforms";
import { HostedService } from "engine/shared/di/HostedService";

export class BlurController extends HostedService {
	readonly blur;

	constructor() {
		super();

		const blur = this.parent(new InstanceComponent(Lighting.WaitForChild("Blur") as BlurEffect));

		this.blur = blur.valuesComponent().get("Size");
		this.blur.addBasicTransform(Transforms.quadOut02);
	}
}


import { HttpService, Workspace } from "@rbxts/services";
import { HostedService } from "engine/shared/di/HostedService";
import { Objects } from "engine/shared/fixes/Objects";
import type { PlayerDataStorage } from "client/PlayerDataStorage";

@injectable
export class CameraController extends HostedService {
	constructor(@inject playerData: PlayerDataStorage) {
		super();

		this.event.readonlyObservableFromInstanceParam(Workspace, "CurrentCamera").subscribe((camera) => {
			if (camera) {
				camera.FieldOfView = playerData.config.get().betterCamera.fov;
			}
		}, true);
		this.event.subscribeRegistration(() =>
			playerData.config
				.fReadonlyCreateBased((c) => c.betterCamera)
				.subscribeWithCustomEquality(
					(betterCamera) => {
						$log("better_camera set to " + HttpService.JSONEncode(betterCamera));
						Workspace.SetAttribute("camera_improved", betterCamera?.improved === true);
						Workspace.SetAttribute("camera_playerCentered", betterCamera?.playerCentered === true);
						Workspace.SetAttribute("camera_strictFollow", betterCamera?.strictFollow === true);

						if (Workspace.CurrentCamera) {
							Workspace.CurrentCamera.FieldOfView = betterCamera.fov;
						}
					},
					Objects.deepEquals,
					true,
				),
		);
	}
}


import { Lighting } from "@rbxts/services";
import { HostedService } from "engine/shared/di/HostedService";
import type { PlayerDataStorage } from "client/PlayerDataStorage";

@injectable
export class DayCycleController extends HostedService {
	constructor(@inject playerData: PlayerDataStorage) {
		super();

		Lighting.SetMinutesAfterMidnight(14 * 60);

		const timePerDayCycle = 20 * 60;
		const getMinutesAfterMidnightTime = () => {
			const config = playerData.config.get().dayCycle;
			if (config.automatic) {
				return (((DateTime.now().UnixTimestampMillis / 1000) % timePerDayCycle) / timePerDayCycle) * (60 * 24);
			}

			return config.manual * 60;
		};

		const update = () => {
			Lighting.SetMinutesAfterMidnight(getMinutesAfterMidnightTime());
		};

		this.event.loop(1 / 4, update);
		this.event.addObservable(playerData.config.fReadonlyCreateBased((c) => c.dayCycle)).subscribe(update);
	}
}


import { Players, Workspace } from "@rbxts/services";
import { Component } from "engine/shared/component/Component";
import { BB } from "engine/shared/fixes/BB";
import type { SharedPlot } from "shared/building/SharedPlot";
import type { SharedPlots } from "shared/building/SharedPlots";

@injectable
export class EnvBlacklistsController extends Component {
	private readonly blacklistInstances: Map<BasePart, BasePart> = new Map();

	constructor(@inject ownPlot: SharedPlot, @inject plots: SharedPlots) {
		super();

		const update = (plot: BasePart, isBanned: boolean) => {
			if (isBanned && !this.blacklistInstances.has(plot)) {
				const shadowPart = new Instance("Part");
				shadowPart.Anchored = true;
				shadowPart.CastShadow = false;
				shadowPart.Size = new Vector3(plot.Size.X, 400, plot.Size.Z);
				shadowPart.Position = plot.Position.add(new Vector3(0, 100, 0));
				shadowPart.Color = new Color3(0.15, 0.15, 0.15);
				shadowPart.Parent = Workspace.FindFirstChild("Obstacles");

				this.blacklistInstances.set(plot, shadowPart);

				shadowPart.Touched.Connect((hit) => {
					if (Players.LocalPlayer.Character) {
						if (
							!hit.IsDescendantOf(Players.LocalPlayer.Character) &&
							!hit.IsDescendantOf(ownPlot.instance)
						) {
							return;
						}
					} else if (!hit.IsDescendantOf(ownPlot.instance)) {
						return;
					}

					if (
						!BB.fromPart(shadowPart)
							.withSize((s) => s.mul(new Vector3(0.9, 1, 0.9)))
							.isPointInside(hit.Position)
					) {
						return;
					}

					hit.PivotTo(new CFrame(ownPlot.getSpawnPosition().add(new Vector3(0, 5, 0))));
					hit.AssemblyLinearVelocity = Vector3.zero;
					hit.AssemblyAngularVelocity = Vector3.zero;
				});
			} else if (!isBanned && this.blacklistInstances.has(plot)) {
				const shadowPart = this.blacklistInstances.get(plot)!;
				this.blacklistInstances.delete(plot);
				shadowPart.Destroy();
			}
		};

		for (const plot of plots.plots) {
			if (plot === ownPlot) continue;

			this.event.subscribeObservable(
				plot.blacklistedPlayers,
				() => update(plot.instance.BuildingArea, plot.isBlacklisted(Players.LocalPlayer)),
				true,
			);

			this.event.subscribeObservable(
				plot.isolationMode,
				() => update(plot.instance.BuildingArea, plot.isBlacklisted(Players.LocalPlayer)),
				true,
			);
		}
	}
}


import { RunService, Workspace } from "@rbxts/services";
import { LocalPlayerController } from "client/controller/LocalPlayerController";
import { LocalPlayer } from "engine/client/LocalPlayer";
import { HostedService } from "engine/shared/di/HostedService";
import { GameEnvironment } from "shared/data/GameEnvironment";
import { Physics } from "shared/Physics";
import type { PlayModeController } from "client/modes/PlayModeController";
import type { PlayerDataStorage } from "client/PlayerDataStorage";
import type { SharedPlot } from "shared/building/SharedPlot";

@injectable
export class GameEnvironmentController extends HostedService {
	constructor(@inject playerData: PlayerDataStorage, @inject plot: SharedPlot, @inject mode: PlayModeController) {
		super();

		this.event.subscribe(RunService.Heartbeat, (dt) => {
			const playerHeight = LocalPlayerController.getPlayerRelativeHeight();
			const gravity = Physics.GetGravityOnHeight(playerHeight);

			Workspace.AirDensity = Physics.GetAirDensityOnHeight(playerHeight);
			Workspace.Gravity = gravity;

			let wind = playerData.config.get().physics.windVelocity;
			if ((wind.X !== 0 || wind.Z !== 0) && mode.get() === "ride") {
				wind = wind.apply((c) => math.clamp(c, -10000, 10000));
				const max = wind.div(10);
				Workspace.GlobalWind = max;

				const apply = (part: BasePart, wind: Vector3) => {
					let delta = wind.sub(part.AssemblyLinearVelocity);
					delta = new Vector3(delta.X, 0, delta.Z) //
						.apply(math.abs)
						.Min(max)
						.mul(delta.apply(math.sign))
						.mul(wind.apply(math.sign));

					delta = new Vector3(
						math.sign(delta.X) === math.sign(wind.X) ? delta.X : 0,
						0,
						math.sign(delta.Z) === math.sign(wind.Z) ? delta.Z : 0,
					);

					part.ApplyImpulse(delta);
				};

				const vel = new Vector3(
					wind.X * (gravity / GameEnvironment.EarthGravity) * dt,
					0,
					wind.Z * (gravity / GameEnvironment.EarthGravity) * dt,
				);

				for (const block of plot.getBlocks()) {
					for (const part of block.GetDescendants()) {
						if (!part.IsA("BasePart")) continue;
						apply(part, vel);
					}
				}

				const playerPart = LocalPlayer.humanoid.get()?.RootPart;
				if (playerPart) {
					apply(
						playerPart,
						new Vector3(
							wind.X * (gravity / GameEnvironment.EarthGravity) * dt,
							0,
							wind.Z * (gravity / GameEnvironment.EarthGravity) * dt,
						),
					);
				}
			}
		});
	}
}


import { Players } from "@rbxts/services";
import { HostedService } from "engine/shared/di/HostedService";
import { PartUtils } from "shared/utils/PartUtils";
import type { PlayerDataStorage } from "client/PlayerDataStorage";
import type { SharedPlots } from "shared/building/SharedPlots";

@injectable
export class GraphicsSettingsController extends HostedService {
	constructor(@inject plots: SharedPlots, @inject playerData: PlayerDataStorage) {
		super();

		const graphics = playerData.config.createBased((x) => x.graphics);
		this.event.subscribeObservable(graphics, ({ localShadows, othersShadows }) => {
			for (const plot of plots.plots) {
				const selfowned = plot.ownerId.get() === Players.LocalPlayer.UserId;

				if (selfowned) {
					PartUtils.applyToAllDescendantsOfType(
						"BasePart",
						plot.instance,
						(child) => (child.CastShadow = localShadows),
					);
				} else {
					PartUtils.applyToAllDescendantsOfType(
						"BasePart",
						plot.instance,
						(child) => (child.CastShadow = othersShadows),
					);
				}
			}
		});

		for (const plot of plots.plots) {
			const selfowned = plot.ownerId.get() === Players.LocalPlayer.UserId;

			this.event.subscribe(plot.instance.DescendantAdded, (child) => {
				if (!child.IsA("BasePart")) return;

				const { localShadows, othersShadows } = graphics.get();
				if (!localShadows && !othersShadows) {
					return;
				}

				if (selfowned) {
					if (localShadows) {
						child.CastShadow = true;
					}
				} else {
					if (othersShadows) {
						child.CastShadow = true;
					}
				}
			});
		}
	}
}


import { ReplicatedStorage } from "@rbxts/services";
import { Component } from "engine/shared/component/Component";
import type { ReadonlyObservableValue } from "engine/shared/event/ObservableValue";
import type { SharedPlot } from "shared/building/SharedPlot";

export class MirrorVisualizer extends Component {
	private readonly plot: ReadonlyObservableValue<SharedPlot>;
	private readonly mirrorMode: ReadonlyObservableValue<MirrorMode>;
	private readonly template;

	constructor(plot: ReadonlyObservableValue<SharedPlot>, mirrorMode: ReadonlyObservableValue<MirrorMode>) {
		super();

		this.plot = plot;
		this.mirrorMode = mirrorMode;
		this.template = this.asTemplate(ReplicatedStorage.Assets.Helpers.Mirror);

		this.event.subscribeObservable(this.mirrorMode, () => this.recreate(), true);
		this.event.subscribeObservablePrev(this.plot, (plot, prev) => {
			prev?.instance.FindFirstChild("Mirrors")?.ClearAllChildren();
			this.recreate();
		});

		this.onEnable(() => this.recreate());
		this.onDisable(() => this.plot.get()?.instance.FindFirstChild("Mirrors")?.ClearAllChildren());
	}

	private recreate() {
		const plot = this.plot.get();

		let mirrors = plot.instance.FindFirstChild("Mirrors");
		if (!mirrors) {
			mirrors = new Instance("Folder");
			mirrors.Name = "Mirrors";
			mirrors.Parent = plot.instance;
		}

		mirrors.ClearAllChildren();

		const mode = this.mirrorMode.get();
		const axes: (readonly [CFrame, number])[] = [];

		if (mode.y !== undefined)
			axes.push([
				CFrame.fromAxisAngle(Vector3.xAxis, math.pi / 2).add(new Vector3(0, mode.y, 0)),
				plot.bounds.originalSize.X,
			]);
		if (mode.x !== undefined)
			axes.push([CFrame.identity.add(new Vector3(0, 0, mode.x)), plot.bounds.originalSize.X]);
		if (mode.z !== undefined)
			axes.push([
				CFrame.fromAxisAngle(Vector3.yAxis, math.pi / 2).add(new Vector3(mode.z, 0, 0)),
				plot.bounds.originalSize.Z,
			]);

		for (const [cframe, size] of axes) {
			const mirror = this.template();
			mirror.PivotTo(plot.getCenter().ToWorldSpace(cframe));
			mirror.Size = new Vector3(size, 1, 0.001);
			mirror.Parent = mirrors;
		}
	}
}


import { CollectionService } from "@rbxts/services";
import { initKillPlane, initLavaKillPlane } from "client/controller/KillPlane";
import { HostedService } from "engine/shared/di/HostedService";
import { Signal } from "engine/shared/event/Signal";

export class ObstaclesController extends HostedService {
	constructor() {
		super();

		this.event.subscribeRegistration(() => {
			const subs: SignalConnection[] = [];

			for (const lava of CollectionService.GetTagged("Lava")) {
				if (!lava.IsA("BasePart")) continue;
				subs.push(initLavaKillPlane(lava));
			}

			for (const destroyer of CollectionService.GetTagged("Destroyer")) {
				if (!destroyer.IsA("BasePart")) continue;
				subs.push(initKillPlane(destroyer));
			}

			return Signal.multiConnection(...subs);
		});
	}
}


import { ContextActionService } from "@rbxts/services";
import { InputController } from "engine/client/InputController";
import { LocalPlayer } from "engine/client/LocalPlayer";
import { Component } from "engine/shared/component/Component";
import { ComponentEvents } from "engine/shared/component/ComponentEvents";
import { HostedService } from "engine/shared/di/HostedService";
import { Keys } from "engine/shared/fixes/Keys";
import { SharedRagdoll } from "shared/SharedRagdoll";
import type { PlayerDataStorage } from "client/PlayerDataStorage";
import type { ReadonlyObservableValue } from "engine/shared/event/ObservableValue";

const { isPlayerRagdolling } = SharedRagdoll;
function initAutoRagdoll(event: ComponentEvents, humanoid: Humanoid, enabled: ReadonlyObservableValue<boolean>) {
	let prevSpeed: number | undefined;

	event.loop(0, () => {
		if (!enabled.get()) return;
		if (!humanoid.RootPart) return;
		if (humanoid.Sit) return;
		if (isPlayerRagdolling(humanoid)) return;

		const state = humanoid.GetState();
		if (
			state === Enum.HumanoidStateType.Physics ||
			state === Enum.HumanoidStateType.GettingUp ||
			state === Enum.HumanoidStateType.Jumping
		) {
			prevSpeed = undefined;
			return;
		}

		const newspeed = humanoid.RootPart.AssemblyLinearVelocity.Magnitude;
		if (prevSpeed === undefined) {
			prevSpeed = newspeed;
			return;
		}

		const diff = math.abs(newspeed - prevSpeed);
		prevSpeed = newspeed;

		const difference = state === Enum.HumanoidStateType.Landed ? 100 : 50;
		if (diff < difference) return;

		$trace("Ragdolled with a diff of", diff);
		SharedRagdoll.event.send(true);
	});
}
function initRagdollUp(
	event: ComponentEvents,
	humanoid: Humanoid,
	autoRecoveryByTimer: ReadonlyObservableValue<boolean>,
	autoRecoveryByMoving: ReadonlyObservableValue<boolean>,
) {
	const player = LocalPlayer.player;

	while (!player.Character?.FindFirstChild("ConstraintsFolder")) {
		task.wait();
	}

	const getUpTime = 4;
	const actionName = "ragdoll_autoRecovery";
	event.subscribeRegistration(() =>
		SharedRagdoll.subscribeToPlayerRagdollChange(humanoid, () => {
			if (isPlayerRagdolling(humanoid)) {
				humanoid.SetStateEnabled("GettingUp", false);
				humanoid.SetStateEnabled("Swimming", false);
				humanoid.SetStateEnabled("Seated", false);
				humanoid.ChangeState("Physics");

				if (humanoid.GetState() !== Enum.HumanoidStateType.Dead && humanoid.Health > 0) {
					task.spawn(() => {
						const unragdollIfSlow = () => {
							if (humanoid.Health <= 0) return;
							if (!humanoid.RootPart) return;
							if (!isPlayerRagdolling(humanoid)) return;

							if (humanoid.RootPart.AssemblyLinearVelocity.Magnitude < 10) {
								SharedRagdoll.event.send(false);
								ContextActionService.UnbindAction(actionName);

								return true;
							}
						};

						task.wait(getUpTime);

						if (autoRecoveryByTimer.get() && unragdollIfSlow()) return;

						if (autoRecoveryByMoving.get()) {
							ContextActionService.UnbindAction(actionName);
							ContextActionService.BindActionAtPriority(
								actionName,
								() => {
									task.spawn(() => SharedRagdoll.event.send(false));

									ContextActionService.UnbindAction(actionName);
									return Enum.ContextActionResult.Pass;
								},
								false,
								2000 + 1,
								...Enum.PlayerActions.GetEnumItems(),
							);
						}

						if (autoRecoveryByTimer.get()) {
							while (task.wait()) {
								if (unragdollIfSlow()) {
									break;
								}
							}
						}
					});
				}
			} else {
				humanoid.ChangeState("GettingUp");
				humanoid.SetStateEnabled("GettingUp", true);
				humanoid.SetStateEnabled("Swimming", true);
				humanoid.SetStateEnabled("Seated", true);

				const character = player.Character;
				if (character && !(character.WaitForChild("Humanoid") as Humanoid).Sit) {
					character.PivotTo(character.GetPivot().add(new Vector3(0, 2, 0)));
				}
			}

			humanoid.AutoRotate = !isPlayerRagdolling(humanoid);
		}),
	);
}
function initRagdollKey(event: ComponentEvents, key: ReadonlyObservableValue<{ triggerKey: KeyCode | undefined }>) {
	const actionName = "ragdoll";

	function bind(key: KeyCode, func: () => void) {
		ContextActionService.BindAction(
			actionName,
			(name, state, input) => {
				if (state === Enum.UserInputState.Begin) {
					func();
				}

				return Enum.ContextActionResult.Pass;
			},
			InputController.inputType.get() === "Touch",
			Keys.Keys[key],
		);

		ContextActionService.SetDescription(actionName, "funny falling");
		ContextActionService.SetImage(actionName, "rbxassetid://110824406341723");
		ContextActionService.SetPosition(actionName, new UDim2(0, 150, 0, 50));
	}
	function unbind() {
		ContextActionService.UnbindAction(actionName);
	}

	let can = true;
	const rebind = () => {
		const { triggerKey } = key.get();
		can = true;

		unbind();
		if (!triggerKey) return;

		bind(triggerKey, () => {
			if (!can) return;

			const humanoid = LocalPlayer.humanoid.get();
			if (!humanoid || humanoid.Sit) return;

			const ragdolling = isPlayerRagdolling(humanoid);
			can = false;
			task.delay(1, () => (can = true));
			task.spawn(() => SharedRagdoll.event.send(!ragdolling));
		});
	};

	event.subscribeObservable(key, rebind);
	event.subscribeObservable(InputController.inputType, rebind);
	event.onEnable(rebind);
}

@injectable
export class RagdollController extends HostedService {
	constructor(@inject playerDataStorage: PlayerDataStorage) {
		super();

		initRagdollKey(
			this.event,
			playerDataStorage.config.createBased((c) => c.ragdoll),
		);
		this.event.subscribeObservable(
			LocalPlayer.humanoid,
			(humanoid) => {
				if (!humanoid) return;

				task.delay(1, () => {
					const character = LocalPlayer.character.get();
					if (!character) return;

					const component = new Component();
					const event = new ComponentEvents(component);

					initRagdollUp(
						event,
						humanoid,
						playerDataStorage.config.createBased((c) => c.ragdoll.autoRecovery),
						playerDataStorage.config.createBased((c) => c.ragdoll.autoRecoveryByMoving),
					);
					initAutoRagdoll(
						event,
						humanoid,
						playerDataStorage.config.createBased((c) => c.ragdoll.autoFall),
					);

					humanoid.Died.Once(() => component.disable());
					event.subscribe(character.GetPropertyChangedSignal("Parent"), () => {
						if (character.Parent) return;
						component.destroy();
					});

					component.enable();
				});
			},
			true,
		);
	}
}


import { Workspace } from "@rbxts/services";
import { MusicPlaylist } from "client/controller/sound/MusicPlaylist";
import { SoundController } from "client/controller/SoundController";
import { HostedService } from "engine/shared/di/HostedService";
import type { PlayModeController } from "client/modes/PlayModeController";
import type { PlayerDataStorage } from "client/PlayerDataStorage";

type Sounds = {
	readonly Space: Folder;
	readonly BuildingBackground: Folder;
};

@injectable
export class MusicController extends HostedService {
	private readonly musicFolder = SoundController.getUISounds<{ Music: Sounds }>().Music;

	private readonly spacePlaylist = new MusicPlaylist(this.musicFolder.Space.GetChildren() as Sound[], 15);
	private readonly buildingBackgroundPlaylist = new MusicPlaylist(
		this.musicFolder.BuildingBackground.GetChildren() as Sound[],
		25,
	);
	private readonly allPlaylists: MusicPlaylist[] = [this.spacePlaylist, this.buildingBackgroundPlaylist];
	readonly stopAll = () => this.allPlaylists.forEach((v) => v.stop());

	constructor(@inject playerData: PlayerDataStorage, @inject playerMode: PlayModeController) {
		super();

		this.event.subscribe(playerData.config.changed, (name) => {
			this.allPlaylists.forEach((v) => v.setVolume(name.music / 100));
		});

		this.event.subscribe(playerMode.playmode.changed, (mode) => {
			this.stopAll();
			if (mode === "build") this.buildingBackgroundPlaylist.play();
		});

		const gotInSpace = () => {
			if (this.spacePlaylist.currentSound) return;
			this.stopAll();
			this.spacePlaylist.play();
		};

		const gotFromSpace = () => {
			if (!this.spacePlaylist.currentSound) return;
			this.stopAll();
		};

		let grav = Workspace.Gravity;
		this.event.subscribe(Workspace.GetPropertyChangedSignal("Gravity"), () => {
			const newGrav = Workspace.Gravity;
			if (grav !== newGrav) {
				if (newGrav <= 0) gotInSpace();
				else gotFromSpace();
			}
			grav = newGrav;
		});

		this.onDisable(() => this.stopAll());
	}
}


import { TweenService } from "@rbxts/services";

const shuffleArray = <T>(array: T[]) => {
	const result: T[] = [...array];
	for (let i = 0; i < result.size(); i++) {
		const indx = math.floor(math.random() * result.size());
		[result[i], result[indx]] = [result[indx], result[i]];
	}
	return result;
};

export class MusicPlaylist {
	readonly allSounds: { originalVolume: number; updatedVolume: number; sound: Sound }[];
	shuffledSoundList: typeof this.allSounds = [];
	songIndex: number = 0;

	currentSound: Sound | undefined;
	private currentSoundEndEvent: RBXScriptConnection | undefined;

	constructor(
		sounds: Sound[],
		readonly interval: number,
	) {
		this.allSounds = sounds.map((v) => ({ originalVolume: v.Volume, updatedVolume: v.Volume, sound: v }));
		this.shuffledSoundList = shuffleArray(this.allSounds);
	}

	playSpecificByName(name: string) {
		this.currentSound = this.allSounds.find((v) => v.sound.Name === name)?.sound;
		if (!this.currentSound) throw `No music with the name "${name}" was found`;
		this.currentSound.Play();
	}

	setVolume(volume: number) {
		for (const v of this.allSounds) {
			v.updatedVolume = v.sound.Volume = v.originalVolume * volume;
		}
	}

	play() {
		if (this.allSounds.isEmpty()) return;
		const sound = this.shuffledSoundList[this.songIndex];
		this.currentSound = sound.sound;
		this.currentSound.Volume = sound.updatedVolume;
		this.currentSoundEndEvent = this.currentSound.Ended.Once(() => {
			wait(this.interval);
			this.play();
		});

		this.currentSound.Play();
		this.songIndex++;
		if (this.songIndex >= this.allSounds.size()) {
			this.songIndex = 0;
			this.shuffledSoundList = shuffleArray(this.allSounds);
		}
	}

	stop() {
		this.currentSoundEndEvent?.Disconnect();

		if (this.currentSound) {
			const sound = this.currentSound;
			const tween = TweenService.Create(sound, new TweenInfo(2), { Volume: 0 });

			tween.Play();
			tween.Completed.Connect(() => sound.Stop());
		}
		this.currentSound = undefined;
	}
}


import { Players } from "@rbxts/services";
import { SavePopup } from "client/gui/popup/SavePopup";
import { ServiceIntegrityChecker } from "client/integrity/ServiceIntegrityChecker";
import { PlayerDataStorage } from "client/PlayerDataStorage";
import { BuildingDiffer } from "client/tutorial2/BuildingDiffer";
import { TestTutorial } from "client/tutorial2/tutorials/TestTutorial";
import { TutorialStarter } from "client/tutorial2/TutorialStarter";
import { TextButtonControl } from "engine/client/gui/Button";
import { Control } from "engine/client/gui/Control";
import { Interface } from "engine/client/gui/Interface";
import { InputController } from "engine/client/InputController";
import { ComponentInstance } from "engine/shared/component/ComponentInstance";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { HostedService } from "engine/shared/di/HostedService";
import { Element } from "engine/shared/Element";
import { PlayerRank } from "engine/shared/PlayerRank";
import type { PopupController } from "client/gui/PopupController";
import type { TutorialsService } from "client/tutorial/TutorialService";
import type { Component } from "engine/shared/component/Component";
import type { InstanceComponentParentConfig } from "engine/shared/component/InstanceComponent";
import type { GameHost } from "engine/shared/GameHost";
import type { GameHostBuilder } from "engine/shared/GameHostBuilder";
import type { Switches } from "engine/shared/Switches";
import type { ReadonlyPlot } from "shared/building/ReadonlyPlot";

class VerticalList extends Control {
	constructor() {
		const gui = Element.create(
			"Frame",
			{
				BackgroundTransparency: 1,
				BorderSizePixel: 0,
				Size: new UDim2(0, 200, 0, 0),
				AutomaticSize: Enum.AutomaticSize.Y,
			},
			{ list: Element.create("UIListLayout", { FillDirection: Enum.FillDirection.Vertical }) },
		);
		super(gui);
	}

	override parent<T extends Component>(child: T, config?: InstanceComponentParentConfig): T {
		const isControl = (component: Component): component is InstanceComponent<GuiObject> => {
			return child instanceof InstanceComponent && (child.instance as Instance).IsA("GuiObject");
		};

		if (
			isControl(child) &&
			child.instance.Size.X.Scale === 0 &&
			child.instance.Size.X.Offset === 0 &&
			child.instance.Size.Y.Scale === 0 &&
			child.instance.Size.Y.Offset === 0
		) {
			child.instance.Size = new UDim2(1, 0, 0, 30);
		}

		return super.parent(child, config);
	}

	addLabel(): Control<TextLabel> {
		return this.parent(new Control(Element.create("TextLabel", { AutomaticSize: Enum.AutomaticSize.Y })));
	}
	addTextBox(placeholder: string, text?: string): Control<TextBox> {
		return this.parent(
			new Control(
				Element.create("TextBox", {
					PlaceholderText: placeholder,
					Text: text ?? undefined,
					ClearTextOnFocus: false,
				}),
			),
		);
	}
	addButton(text: string, action: () => void): TextButtonControl {
		return this.parent(TextButtonControl.create({ Text: text }, action));
	}
}

@injectable
export class AdminGui extends HostedService {
	static initializeIfAdminOrStudio(host: GameHostBuilder) {
		if (!PlayerRank.isAdmin(Players.LocalPlayer)) return;
		host.services.registerService(this);
	}

	constructor(@inject di: DIContainer) {
		super();

		const screen = Element.create("ScreenGui", { Name: "Admin", Enabled: false, Parent: Interface.getPlayerGui() });
		ComponentInstance.init(this, screen);
		ServiceIntegrityChecker.whitelistInstance(screen);

		this.event.onInputBegin((input) => {
			if (input.UserInputType !== Enum.UserInputType.Keyboard) return;
			if (input.KeyCode !== Enum.KeyCode.F7) return;
			if (!InputController.isShiftPressed()) return;

			screen.Enabled = !screen.Enabled;
		});

		const list = this.parent(new VerticalList());
		list.instance.Parent = screen;
		list.addButton("[Hide]", () => (screen.Enabled = false));

		const subframe = Element.create("Frame", {
			BackgroundTransparency: 1,
			BorderSizePixel: 0,
			Size: new UDim2(0, 200, 0, 0),
			AutomaticSize: Enum.AutomaticSize.Y,
			Position: new UDim2(0, 200, 0, 0),
		});
		subframe.Parent = screen;
		ComponentInstance.init(this, subframe);

		const openSub = (control: Control) => {
			subframe.ClearAllChildren();
			this.parent(control);
			control.instance.Parent = subframe;
		};
		const openVertical = (setup: (control: VerticalList) => void) => {
			const sub = new VerticalList();
			setup(sub);

			openSub(sub);
		};

		//

		list.addButton("Switches", () => {
			openVertical((sub) => {
				const map = asMap(di.resolve<Switches>().registered).mapToMap((k, v) =>
					$tuple(k + " " + (v.get() ? "+" : "-"), (di: DIContainer, btn: TextButtonControl) => {
						v.set(!v.get());
						btn.text.set(k + " " + (v.get() ? "+" : "-"));
					}),
				);
				for (const [k, v] of map) {
					const btn = sub.addButton(k, () => v(di, btn));
				}
			});
		});

		list.addButton("Load slot", () => {
			openVertical((sub) => {
				const tb = sub.addTextBox("Player ID", "238427763");
				sub.addButton("Open saves", () => {
					const pds = PlayerDataStorage.forPlayer(tonumber(tb.instance.Text)!);
					const scope = di.beginScope((builder) => {
						builder.registerSingletonValue(pds);
					});

					const popup = scope.resolveForeignClass(SavePopup);
					const wrapper = new Control(popup.instance);
					wrapper.cacheDI(pds);
					wrapper.parent(popup);
					popup.onDisable(() => wrapper.destroy());

					scope.resolve<PopupController>().showPopup(wrapper);
				});
			});
		});

		list.addButton("Tutorials", () => {
			openVertical((sub) => {
				sub.addButton("Set BEFORE", () => BuildingDiffer.setBefore(di.resolve<ReadonlyPlot>()));
				sub.addButton("Print DIFF", () =>
					print(BuildingDiffer.serializeDiffToTsCode(di.resolve<ReadonlyPlot>())),
				);
				sub.addButton("Print FULL", () =>
					print(BuildingDiffer.serializePlotToTsCode(di.resolve<ReadonlyPlot>())),
				);

				for (const tutorial of di.resolve<TutorialsService>().allTutorials) {
					sub.addButton(`run '${tutorial.name}'`, () => di.resolve<TutorialsService>().run(tutorial));
				}

				sub.addButton("[2] Run TestTutorial", () => {
					const stepController = new TutorialStarter();
					TestTutorial.start(stepController, true);
					di.resolve<GameHost>().parent(stepController);
				});
			});
		});
	}
}


import { Players, ReplicatedStorage, RunService, Workspace } from "@rbxts/services";
import { Interface } from "engine/client/gui/Interface";
import { Component } from "engine/shared/component/Component";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { PartUtils } from "shared/utils/PartUtils";
import { VectorUtils } from "shared/utils/VectorUtils";

/**
 * Beacon with a public position setter
 */
export class ManualBeacon extends Component {
	readonly billboard;
	showUpDistance = 30;

	position = Vector3.zero;

	constructor(name: string) {
		super();

		this.billboard = ReplicatedStorage.Assets.Guis.BeaconBillboardGui.Clone();

		PartUtils.applyToAllDescendantsOfType("GuiObject", this.billboard, (gui) => {
			gui.ZIndex = -1;
		});

		this.billboard.Visible = false;
		this.billboard.Position = new UDim2(0, 1, 0, 1);
		this.billboard.Parent = Interface.getUnscaled();
		this.billboard.Title.Text = name;
		this.onEnabledStateChange((enabled) => (this.billboard.Visible = enabled), true);

		this.event.subscribe(RunService.RenderStepped, () => {
			const character = Players.LocalPlayer.Character;
			if (!character) return;

			const distance = this.position.sub(character.GetPivot().Position).Magnitude;
			if (!distance) return;

			const transparencyMultiplier = 0.8;
			const transparency = 1 - math.clamp(distance - this.showUpDistance, 0, 1) * transparencyMultiplier;

			this.billboard.ImageLabel.ImageTransparency = transparency;
			this.billboard.Title.TextTransparency = transparency;
			this.billboard.Distance.TextTransparency = transparency;

			if (transparency >= 1) return;

			this.billboard.Distance.Text =
				distance > 1000 ? `${math.floor(distance / 100) / 10} kst` : `${math.floor(distance)} st`;
			const [screenPos, isVisible] = Workspace.CurrentCamera!.WorldToViewportPoint(this.position);
			const screenSize = Workspace.CurrentCamera!.ViewportSize;
			const adjustableOffset =
				this.billboard.AbsoluteSize.Y > this.billboard.AbsoluteSize.X
					? this.billboard.AbsoluteSize.Y / 2
					: this.billboard.AbsoluteSize.X / 2;
			let [pos_x, pos_y] = [screenPos.X, screenPos.Y];

			const halfScreenX = screenSize.X / 2;
			const halfScreenY = screenSize.Y / 2;

			if (!isVisible || pos_x > screenSize.X || pos_y > screenSize.Y || pos_x < 0 || pos_y < 0) {
				const vector = new Vector2(pos_x - halfScreenX, pos_y - halfScreenY);
				const normalizedVector = VectorUtils.normalizeVector2(vector)
					.mul(new Vector2(screenSize.X, screenSize.Y))
					.add(new Vector2(halfScreenX, halfScreenY));

				[pos_x, pos_y] = [normalizedVector.X, normalizedVector.Y];

				if (screenPos.Z < 0) {
					pos_x = screenSize.X - pos_x;
					pos_y = screenSize.Y - pos_y;
				}

				const d = halfScreenX / halfScreenY;
				let vec = new Vector2(pos_x, pos_y).sub(new Vector2(halfScreenX, halfScreenY));
				vec = new Vector2(math.abs(vec.X), math.abs(vec.Y))
					.sub(new Vector2(halfScreenX, halfScreenY))
					.div(new Vector2(d, 1));
				vec = new Vector2(math.abs(vec.X), math.abs(vec.Y));

				const newTransparency = 1 - (1 - vec.Magnitude / halfScreenX) * (1 - transparency);
				this.billboard.ImageLabel.ImageTransparency = newTransparency;
				this.billboard.Title.TextTransparency = newTransparency;
				this.billboard.Distance.TextTransparency = newTransparency;

				const p1 = adjustableOffset;
				const px1 = screenSize.X - adjustableOffset;
				const py1 = screenSize.Y - adjustableOffset;

				pos_x = math.clamp(pos_x, math.min(p1, px1), math.max(p1, px1));
				pos_y = math.clamp(pos_y, math.min(p1, py1), math.max(p1, py1));
			}

			this.billboard.Position = new UDim2(0, pos_x, 0, pos_y);
		});
	}
}

/**
 * Beacon that gets its position from a PVInstance
 */
export class Beacon extends InstanceComponent<PVInstance> {
	constructor(part: PVInstance, name: string) {
		super(part);

		const beacon = this.parent(new ManualBeacon(name));
		this.event.subscribe(RunService.RenderStepped, () => {
			beacon.position = part.GetPivot().Position;
		});
	}
}


import { RunService } from "@rbxts/services";
import { Interface } from "engine/client/gui/Interface";
import { HostedService } from "engine/shared/di/HostedService";

export class FpsCounterController extends HostedService {
	constructor() {
		super();

		const gui = Interface.getInterface<{ Fps: TextLabel }>().Fps;
		gui.Visible = true;

		let fps = 0;
		this.event.subscribe(RunService.RenderStepped, (dt) => {
			if (fps === math.huge) fps = 0;
			fps = (fps + 1 / dt) / 2;
		});

		this.event.loop(0.5, () => (gui.Text = math.round(fps) + " FPS"));
	}
}


import { ReplicatedStorage, StarterGui, UserInputService } from "@rbxts/services";
import { Anim } from "client/gui/Anim";
import { ButtonControl } from "engine/client/gui/Button";
import { Interface } from "engine/client/gui/Interface";
import { HostedService } from "engine/shared/di/HostedService";
import { ObservableValue } from "engine/shared/event/ObservableValue";
import type { MainScreenLayout } from "client/gui/MainScreenLayout";
import type { SharedPlots } from "shared/building/SharedPlots";

@injectable
export class HideInterfaceController extends HostedService {
	readonly visible = new ObservableValue(true);

	private readonly guis = [Interface.getUnscaled(), Interface.getInterface()] as const;
	private currentUnhideScreen?: ScreenGui;

	constructor(@inject mainScreen: MainScreenLayout, @inject plots: SharedPlots) {
		super();

		this.event.subscribeObservable(this.visible, (visible) => {
			if (visible) {
				this.currentUnhideScreen?.Destroy();
				this.currentUnhideScreen = undefined;
			}
		});

		this.event.subscribe(UserInputService.InputBegan, (input) => {
			if (input.UserInputType !== Enum.UserInputType.Keyboard) {
				return;
			}

			if (UserInputService.GetFocusedTextBox()) {
				return;
			}

			if (!input.IsModifierKeyDown("Shift") || !UserInputService.IsKeyDown(Enum.KeyCode.G)) {
				return;
			}

			this.visible.set(!this.visible.get());
		});

		this.visible.subscribe((visible) => {
			// hide screen guis
			for (const ui of this.guis) {
				ui.Enabled = visible;
			}

			// Hide core gui (excluding backpack)
			StarterGui.SetCoreGuiEnabled(Enum.CoreGuiType.All, visible);
			StarterGui.SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false);

			// Plot owner gui hide
			plots.plots.forEach((plot) => {
				const ownerGui = plot.instance.FindFirstChild(ReplicatedStorage.Assets.Guis.PlotOwnerGui.Name) as
					| BillboardGui
					| undefined;
				if (ownerGui) {
					ownerGui.Enabled = visible;
				}
			});
		});

		const hideButton = this.parentGui(mainScreen.addTopRightButton("Hide", 18887954146));
		this.parent(
			new ButtonControl(hideButton.instance, () => {
				this.currentUnhideScreen = this.createUnhideGui(hideButton.instance);
				this.visible.set(false);
			}),
		);
	}

	private createUnhideGui(button: GuiButton): ScreenGui {
		const [screen, ghost] = Anim.createScreenForAnimating(button);
		ghost.Transparency = 0.8;

		new ButtonControl(ghost, () => this.visible.set(true)).enable();
		return screen;
	}
}


import { MarketplaceService, Players } from "@rbxts/services";
import { ButtonControl } from "engine/client/gui/Button";
import { Control } from "engine/client/gui/Control";
import { Colors } from "engine/shared/Colors";
import { Materials } from "engine/shared/data/Materials";
import { SubmittableValue } from "engine/shared/event/SubmittableValue";
import { Marketplace } from "engine/shared/Marketplace";
import { BuildingManager } from "shared/building/BuildingManager";
import type { ReadonlyObservableValue } from "engine/shared/event/ObservableValue";

class MaterialButton extends ButtonControl {
	constructor(gui: GuiButton, set: () => void, gamePass?: number) {
		super(gui);

		let bought = gamePass === undefined;
		this.event.subscribe(this.activated, () => {
			if (!bought) return;
			set();
		});

		if (gamePass !== undefined) {
			const lockFrame = gui.FindFirstChild("Lock") as Frame;
			const priceLabel = lockFrame.FindFirstChild("TextLabel") as TextLabel;

			if (Marketplace.Gamepass.has(Players.LocalPlayer, gamePass)) {
				bought = true;
				lockFrame.Visible = false;
			} else {
				priceLabel.Text = Marketplace.Gamepass.getPrice(gamePass);
				lockFrame.Visible = true;

				MarketplaceService.PromptGamePassPurchaseFinished.Connect((player, gamePassId, wasPurchased) => {
					if (!wasPurchased) return;
					if (gamePassId !== gamePass) return;

					bought = true;
					lockFrame.Visible = false;
					set();
				});

				this.event.subscribe(this.activated, () => {
					if (bought) return;
					MarketplaceService.PromptGamePassPurchase(Players.LocalPlayer, gamePass);
				});
			}
		}
	}
}

export type MaterialChooserDefinition = GuiObject & {
	readonly Left: GuiObject & {
		readonly Preview: ImageLabel;
	};
	readonly Right: GuiObject & {
		readonly ScrollingFrame: ScrollingFrame & {
			readonly MaterialTemplate: ImageButton & {
				readonly TextLabel: TextLabel;
			};
		};
	};
};
/** Material chooser part */
export class MaterialChooser extends Control<MaterialChooserDefinition> {
	static setColorOfPreview(color: Color4, child: ImageLabel | ImageButton) {
		if (child.Image === "") {
			child.BackgroundColor3 = color.color;
			child.ImageColor3 = Colors.white;
		} else {
			child.BackgroundColor3 = Colors.white;
			child.ImageColor3 = color.color;
		}
	}

	readonly value;

	constructor(
		gui: MaterialChooserDefinition,
		value?: SubmittableValue<Enum.Material>,
		color?: ReadonlyObservableValue<Color4>,
		search?: ReadonlyObservableValue<string>,
	) {
		super(gui);

		value ??= SubmittableValue.from<Enum.Material>(Enum.Material.Plastic);
		this.value = value.asHalfReadonly();

		this.event.subscribeObservable(
			value.value,
			(material) => {
				gui.Left.Preview.Image = Materials.getMaterialTextureAssetId(material);
				if (color) {
					MaterialChooser.setColorOfPreview(color.get(), gui.Left.Preview);
				}
			},
			true,
		);

		if (color) {
			this.event.subscribeObservable(
				color,
				(c) => {
					MaterialChooser.setColorOfPreview(c, gui.Left.Preview);

					for (const child of gui.Right.ScrollingFrame.GetChildren()) {
						if (!child.IsA("ImageButton")) continue;
						MaterialChooser.setColorOfPreview(c, child);
					}
				},
				true,
			);
		}

		const items: (ImageButton & { readonly TextLabel: TextLabel })[] = [];
		const template = this.asTemplate(gui.Right.ScrollingFrame.MaterialTemplate, true);
		for (const [i, material] of ipairs(
			BuildingManager.AllowedMaterials.clone().sort((r, l) => r.Value < l.Value),
		)) {
			const instance = template();
			instance.LayoutOrder = i;
			instance.Parent = gui.Right.ScrollingFrame;

			instance.TextLabel.Text = Materials.getMaterialDisplayName(material).upper();
			instance.BackgroundColor3 = Colors.white;
			instance.Image = Materials.getMaterialTextureAssetId(material);
			items.push(instance);

			// gamepasses disabled as this is uploaded as another game
			// const gamepassid = instance.Name === "Neon" ? GameDefinitions.GAMEPASSES.NeonMaterial : undefined;
			const gamepassid = undefined;
			this.parent(new MaterialButton(instance, () => value.submit(material), gamepassid));
		}

		search!.subscribe((str) => {
			for (const item of items) {
				item.Visible =
					str === undefined ||
					str.size() === 0 ||
					item.TextLabel.Text.lower().find(str.lower())[0] !== undefined;
			}
		});
	}
}


import { RunService } from "@rbxts/services";
import { Control } from "engine/client/gui/Control";

export class BlockPreviewControl extends Control<ViewportFrame> {
	private block?: BlockModel;

	constructor(gui: ViewportFrame, block?: BlockModel) {
		super(gui);
		this.set(block);

		this.event.subscribe(RunService.Heartbeat, (dt) => {
			if (this.block)
				this.block.PivotTo(
					this.block.GetPivot().mul(CFrame.fromEulerAnglesXYZ((math.pi / 3) * dt, (math.pi / 2) * dt, 0)),
				);
		});
	}

	set(block: BlockModel | undefined) {
		this.gui.ClearAllChildren();

		if (block) {
			const size = block.GetExtentsSize();
			const pivot = new CFrame(new Vector3(0, 0.2, -2 - math.max(size.X, size.Y, size.Z))).mul(
				this.block?.GetPivot()?.Rotation ?? CFrame.identity,
			);

			this.block = block.Clone();
			this.block.PivotTo(pivot);
			this.block.Parent = this.gui;
		}
	}
}


import { StarterGui, UserInputService } from "@rbxts/services";
import { LoadingController } from "client/controller/LoadingController";
import { SoundController } from "client/controller/SoundController";
import { Control } from "engine/client/gui/Control";
import { ComponentKeyedChildren } from "engine/shared/component/ComponentKeyedChildren";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { Transforms } from "engine/shared/component/Transforms";
import type { Theme } from "client/Theme";
import type { ToolBase } from "client/tools/ToolBase";
import type { ToolController } from "client/tools/ToolController";

export type HotbarToolButtonControlDefinition = TextButton & {
	readonly ImageLabel: ImageLabel;
	readonly NumLabel: TextLabel;
};

@injectable
export class HotbarButtonControl extends Control<HotbarToolButtonControlDefinition> {
	constructor(
		gui: HotbarToolButtonControlDefinition,
		tools: ToolController,
		tool: ToolBase,
		index: number,
		@inject theme: Theme,
	) {
		super(gui);

		this.instance.Name = tool.getDisplayName();
		this.instance.ImageLabel.Image = tool.getImageID();
		this.instance.NumLabel.Text = tostring(index);

		this.event.subscribe(this.instance.Activated, () => {
			if (LoadingController.isLoading.get()) return;
			tools.selectedTool.set(tool === tools.selectedTool.get() ? undefined : tool);
		});

		this.initializeSimpleTransform("BackgroundColor3");
		this.event.subscribeObservable(
			tools.selectedTool,
			(newTool) => this.themeButton(theme, newTool === tool ? "buttonActive" : "buttonNormal"),
			true,
		);
	}
}

export type HotbarControlDefinition = GuiObject & {
	readonly Tools: GuiObject & {
		readonly GamepadLeft: Frame & { ImageLabel: ImageLabel };
		readonly GamepadRight: Frame & { ImageLabel: ImageLabel };
		readonly ToolTemplate: HotbarToolButtonControlDefinition;
	};
	readonly NameLabel: TextLabel;
};

@injectable
export class HotbarControl extends InstanceComponent<HotbarControlDefinition> {
	private readonly nameLabel;

	constructor(gui: HotbarControlDefinition, @inject toolController: ToolController, @inject di: DIContainer) {
		super(gui);

		// Disable roblox native backpack
		StarterGui.SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false);

		const template = this.asTemplate(this.instance.Tools.ToolTemplate);
		const toolButtons = this.parent(
			new ComponentKeyedChildren<ToolBase, HotbarButtonControl>().withParentInstance(this.instance.Tools),
		);

		this.nameLabel = this.parent(new Control(this.instance.NameLabel));

		this.event.subscribeObservable(
			toolController.tools,
			(tools) => {
				toolButtons.clear();

				let index = 0;
				for (const tool of tools) {
					const button = di.resolveForeignClass(HotbarButtonControl, [
						template(),
						toolController,
						tool,
						++index,
					]);
					toolButtons.add(tool, button);
				}
			},
			true,
		);
		this.event.subscribeImmediately(
			toolController.enabledTools.updated,
			() => {
				for (const [tool, control] of toolButtons.getAll()) {
					const isenabled = toolController.enabledTools.isEnabled(tool);

					control.instance.BackgroundTransparency = isenabled ? 0.2 : 0.6;
					control.instance.Active = isenabled;
					control.instance.Interactable = isenabled;
					control.instance.AutoButtonColor = isenabled;
				}
			},
			true,
		);

		this.event.onPrepare((inputType) => {
			for (const button of toolButtons.getAll()) {
				button[1].instance.NumLabel.Visible = inputType === "Desktop";
			}

			this.instance.Tools.GamepadLeft.Visible = inputType === "Gamepad";
			this.instance.Tools.GamepadRight.Visible = inputType === "Gamepad";
		});

		this.event.onPrepareGamepad(() => {
			this.instance.Tools.GamepadLeft.ImageLabel.Image = UserInputService.GetImageForKeyCode(
				Enum.KeyCode.ButtonL1,
			);
			this.instance.Tools.GamepadRight.ImageLabel.Image = UserInputService.GetImageForKeyCode(
				Enum.KeyCode.ButtonR1,
			);
		});

		const toolChanged = (tool: ToolBase | undefined, prev: ToolBase | undefined) => {
			const duration = tool && prev ? 0.07 : 0.15;

			Transforms.create()
				.if(prev !== undefined, (tr) =>
					tr
						.moveRelative(this.nameLabel.instance, new UDim2(0, 0, 0, -20), { duration })
						.transform(this.nameLabel.instance, "TextTransparency", 1, { duration: duration * 0.8 })
						.then()
						.moveRelative(this.nameLabel.instance, new UDim2(0, 0, 0, 20)),
				)
				.then()
				.func(() => (this.instance.NameLabel.Text = tool?.getDisplayName() ?? ""))
				.if(tool !== undefined, (tr) =>
					tr
						.then()
						.moveRelative(this.nameLabel.instance, new UDim2(0, 0, 0, 20))
						.transform(this.nameLabel.instance, "TextTransparency", 1)
						.moveRelative(this.nameLabel.instance, new UDim2(0, 0, 0, -20), { duration })
						.transform(this.nameLabel.instance, "TextTransparency", 0, { duration: duration * 0.8 })
						.then(),
				)
				.run(this.nameLabel.instance);

			// Play sound
			SoundController.getUISounds().Click.Play();
		};
		this.event.subscribeObservablePrev(toolController.selectedTool, toolChanged, true);
		this.instance.NameLabel.Text = "";
	}
}