-- [[ Rscripts Risk Notice ]] -- This script is not verified by rscripts.net. Deal with caution. -- -- Stay safe: -- • Never log in on unofficial Roblox sites or lookalike domains. -- • Real Roblox links use roblox.com (check the .com ending). -- • Treat fake Roblox login / "claim reward" pages as phishing. -- [[ End Rscripts Risk Notice ]] local Players = game:GetService("Players") local UIS = game:GetService("UserInputService") local CollectionService = game:GetService("CollectionService") local HttpService = game:GetService("HttpService") local Workspace = game:GetService("Workspace") local TweenService = game:GetService("TweenService") local LocalPlayer = Players.LocalPlayer local RecipeBook = LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("RecipeBook") require(RecipeBook:WaitForChild("RecipeBookHandler")) local CoreFrame = RecipeBook:WaitForChild("CoreFrame") local ServerSettings = game.ReplicatedStorage:WaitForChild("ServerSettings") local Title = CoreFrame:WaitForChild("Title") local Items = CoreFrame:WaitForChild("Items") local DiscoveredRecipe = RecipeBook:WaitForChild("DiscoveredRecipe") -- ============================ diagnostics + live-reload ============================ local DIAG = getgenv().__furnace_diag if type(DIAG) ~= "table" then DIAG = { steps = {} } getgenv().__furnace_diag = DIAG end DIAG.error = nil DIAG.steps = {} local function diag(step) DIAG.steps[#DIAG.steps + 1] = string.format("%.1fs %s", os.clock(), step) end local connected = typeof(STATE) == "table" and type(STATE.connect) == "function" local function connectSignal(sig, fn) if connected then STATE.connect(sig, fn) else sig:Connect(fn) end end -- ============================ startup dedupe ============================ local function dedupe() for _, d in ipairs(CoreFrame:GetDescendants()) do local n = d.Name if n == "UnresearchedToggle" or n == "FurnaceButton" or n == "PurgeButton" or n == "FeedBridgeButton" or n:sub(1, 13) == "ResizeHandle_" then pcall(function() d:Destroy() end) end end local sigs = { CoreFrame:GetPropertyChangedSignal("Visible"), CoreFrame:GetPropertyChangedSignal("Position"), CoreFrame:GetPropertyChangedSignal("Size"), Title.InputBegan, CoreFrame.DescendantAdded, } for _, s in ipairs(sigs) do for _, c in ipairs(getconnections(s)) do pcall(function() c:Disconnect() end) end end end dedupe() -- ============================ open/close on G ============================ local function getPrompt() for _, prompt in ipairs(CollectionService:GetTagged("OpenRecipeBook")) do if not prompt:IsA("ProximityPrompt") then continue end local root = prompt while root.Parent and not root:IsA("Workspace") and not root:IsA("Lighting") do root = root.Parent end if root:IsA("Workspace") or root:IsA("Lighting") then return prompt end end for _, prompt in ipairs(CollectionService:GetTagged("OpenRecipeBook")) do if prompt:IsA("ProximityPrompt") then return prompt end end return nil end local function findHandler() for _, t in ipairs(filtergc("table", { Values = { CoreFrame } }, false)) do if type(t) == "table" and t.Frame == CoreFrame and type(t.CloseUI) == "function" then return t end end return nil end local function keepOpenOnMove() local h = findHandler() if not h then return end if type(h.Settings) == "table" then h.Settings.CloseOnMove = false end if h._moveConns then for _, c in pairs(h._moveConns) do if c then pcall(function() c:Disconnect() end) end end end end keepOpenOnMove() local toggling = false local function onKeyDown(input, _gameProcessed) if input.KeyCode ~= Enum.KeyCode.G then return end if UIS:GetFocusedTextBox() then return end if toggling then return end toggling = true task.defer(function() if CoreFrame.Visible then local h = findHandler() if h then h:CloseUI() else CoreFrame.Visible = false end else local prompt = getPrompt() if not prompt then warn("PortableRecipeBook: no OpenRecipeBook prompt found") else fireproximityprompt(prompt) keepOpenOnMove() end end toggling = false end) end connectSignal(UIS.InputBegan, onKeyDown) -- ============================ heal empty recipe list ============================ local lastHeal = 0 local function healEmptyItems() if not CoreFrame.Visible then return end for _, c in ipairs(Items:GetChildren()) do if c:IsA("TextButton") then return end end if #DiscoveredRecipe:GetChildren() == 0 then return end local now = os.clock() if now - lastHeal < 1.5 then return end lastHeal = now local prompt = getPrompt() if prompt then pcall(fireproximityprompt, prompt) end end task.spawn(function() while true do task.wait(0.5) healEmptyItems() if typeof(STATE) == "table" and type(STATE.alive) == "function" and not STATE.alive() then break end end end) -- ============================ unresearched highlight ============================ local HIGHLIGHT_FILL = Color3.fromRGB(255, 45, 55) local HIGHLIGHT_OUTLINE = Color3.fromRGB(255, 30, 40) local FILL_TRANSPARENCY = 0.55 local OUTLINE_TRANSPARENCY = 0.1 local highlightBtn local highlightEnabled = false local refreshThread = nil local active = {} local function getResearched() local set = {} local raw = ServerSettings:GetAttribute("Researched") if raw and raw ~= "" then local ok, decoded = pcall(HttpService.JSONDecode, HttpService, raw) if ok and type(decoded) == "table" then for _, name in ipairs(decoded) do set[name] = true end end end return set end local function refreshHighlights() if not highlightEnabled then return end local researched = getResearched() local current = {} for _, d in ipairs(Workspace:GetDescendants()) do if d:IsA("BasePart") and d.Name == "Root" and d.Parent:IsA("Model") then local mdl = d.Parent local tier = mdl:FindFirstChild("Tier") if tier and tier:IsA("IntValue") and not researched[mdl.Name] then current[mdl] = true local hl = active[mdl] if not hl or not hl.Parent then if hl then hl:Destroy() end hl = Instance.new("Highlight") hl.Name = "UnresearchedHighlight" hl.Adornee = mdl hl.FillColor = HIGHLIGHT_FILL hl.FillTransparency = FILL_TRANSPARENCY hl.OutlineColor = HIGHLIGHT_OUTLINE hl.OutlineTransparency = OUTLINE_TRANSPARENCY hl.DepthMode = Enum.HighlightDepthMode.AlwaysOnTop hl.Parent = mdl active[mdl] = hl end end end end for mdl, hl in pairs(active) do if not current[mdl] then if hl then pcall(function() hl:Destroy() end) end active[mdl] = nil end end end local function clearHighlights() for _, hl in pairs(active) do if hl then pcall(function() hl:Destroy() end) end end active = {} end local function stopHighlighting() highlightEnabled = false if refreshThread then task.cancel(refreshThread) refreshThread = nil end clearHighlights() end local function startHighlighting() highlightEnabled = true refreshHighlights() refreshThread = task.spawn(function() while highlightEnabled do task.wait(0.5) refreshHighlights() end end) end local function setButtonAppearance() if not highlightBtn then return end if highlightEnabled then highlightBtn.Text = "Unresearched: ON" highlightBtn.TextColor3 = Color3.fromRGB(255, 220, 220) highlightBtn.BackgroundColor3 = Color3.fromRGB(150, 25, 32) else highlightBtn.Text = "Unresearched: OFF" highlightBtn.TextColor3 = Color3.fromRGB(220, 220, 220) highlightBtn.BackgroundColor3 = Color3.fromRGB(25, 25, 25) end end local function onTogglePressed() if highlightEnabled then stopHighlighting() else startHighlighting() end setButtonAppearance() end -- ============================ shared helpers ============================ local pickUpRemote, dropRemote local furnaceBtn, purgeBtn, bridgeBtn local function makeButton(name, parent, anchor, position, size, text) local btn = Instance.new("TextButton") btn.Name = name btn.AnchorPoint = anchor btn.Position = position btn.Size = size btn.ZIndex = 10 btn.BackgroundColor3 = Color3.fromRGB(25, 25, 25) btn.BackgroundTransparency = 0.25 btn.Text = text btn.TextColor3 = Color3.fromRGB(220, 220, 220) btn.TextSize = 16 btn.Font = Enum.Font.GothamSemibold local corner = Instance.new("UICorner") corner.CornerRadius = UDim.new(0, 6) corner.Parent = btn btn.Parent = parent return btn end local function createToggleButton() if highlightBtn and highlightBtn.Parent then return highlightBtn end highlightBtn = makeButton("UnresearchedToggle", Title, Vector2.new(1, 0.5), UDim2.new(1, -10, 0.5, 0), UDim2.new(0, 155, 0, 30), "Unresearched: OFF") connectSignal(highlightBtn.Activated, onTogglePressed) return highlightBtn end createToggleButton() local function makeLabelSetter(getBtn, busyBg, busyText) return function(text, busy) local btn = getBtn() if not btn then return end btn.Text = text if busy then btn.BackgroundColor3 = busyBg btn.TextColor3 = busyText else btn.BackgroundColor3 = Color3.fromRGB(25, 25, 25) btn.TextColor3 = Color3.fromRGB(220, 220, 220) end end end local function getRemotes() local Packet = require(game:GetService("ReplicatedStorage"):WaitForChild("Packet")) pickUpRemote = Packet("PickUp", Packet.Instance) dropRemote = Packet("Drop", Packet.CFrameF24U8) end local function charHrp() local char = LocalPlayer.Character if not char then return nil end return char:FindFirstChild("HumanoidRootPart") end local function moveChar(pos) local char = LocalPlayer.Character if char then pcall(function() char:SetPrimaryPartCFrame(CFrame.new(pos)) end) end end local function ensureNear(pos, range, settle) local hrp = charHrp() if hrp and (hrp.Position - pos).Magnitude > range then moveChar(pos) task.wait(settle or 0.25) end end local function unequipAll() local char = LocalPlayer.Character local hum = char and char:FindFirstChildOfClass("Humanoid") if hum then pcall(function() hum:UnequipTools() end) end end local function hasTool() local char = LocalPlayer.Character return char and char:FindFirstChildOfClass("Tool") or nil end local function countTools(matchName, nameSet) local n = 0 local char = LocalPlayer.Character if char then for _, t in ipairs(char:GetChildren()) do if t:IsA("Tool") and (matchName == nil or t.Name == matchName) and (nameSet == nil or nameSet[t.Name]) then n = n + 1 end end end local bp = LocalPlayer:FindFirstChildOfClass("Backpack") if bp then for _, t in ipairs(bp:GetChildren()) do if t:IsA("Tool") and (matchName == nil or t.Name == matchName) and (nameSet == nil or nameSet[t.Name]) then n = n + 1 end end end return n end local function countAllHeld() return countTools() end local function countDirt() return countTools("Dirt Cube") end local function countHeldNames(nameSet) return countTools(nil, nameSet) end local function nextCubeTool(nameSet) local bp = LocalPlayer:FindFirstChildOfClass("Backpack") if bp then for _, t in ipairs(bp:GetChildren()) do if t:IsA("Tool") and nameSet[t.Name] then return t end end end return nil end local function equipTool(nxt, isRunning) if not nxt then return end local hum = LocalPlayer.Character and LocalPlayer.Character:FindFirstChildOfClass("Humanoid") if hum then pcall(function() hum:EquipTool(nxt) end) local e0 = os.clock() while os.clock() - e0 < 0.6 and isRunning() do if hasTool() == nxt then break end task.wait(0.02) end end end local function resetLabelLater(setLabel, isRunning, base, delaySec) task.delay(delaySec, function() if not isRunning() then setLabel(base, false) end end) end local function teleportBack(origCF, prefix) if not origCF then if prefix then diag(prefix .. ": teleportBack no origCF") end return end local char = LocalPlayer.Character if not char then if prefix then diag(prefix .. ": teleportBack no character") end return end local ok2, err2 = pcall(function() char:SetPrimaryPartCFrame(origCF) end) if prefix then diag(string.format(prefix .. ": teleportBack ok=%s err=%s orig=%s", tostring(ok2), tostring(err2), tostring(origCF.Position))) end end local function getFolderCubeNames() local names = {} local folder = Workspace:FindFirstChild("Cubes") if folder then for _, c in ipairs(folder:GetChildren()) do if c:IsA("Tool") then names[c.Name] = true end end end return names end local function getAllCubes() local list = {} local folder = Workspace:FindFirstChild("Cubes") if folder then for _, c in ipairs(folder:GetChildren()) do if c:IsA("Tool") then list[#list + 1] = c end end end return list end local function getDirtCubes() local list = {} local folder = Workspace:FindFirstChild("Cubes") if folder then for _, c in ipairs(folder:GetChildren()) do if c.Name == "Dirt Cube" and c:IsA("Tool") then list[#list + 1] = c end end end return list end -- tier of a cube tool, falling back to the recipe book config when the tool's -- own Tier value is missing; cubes with no tier count as 0 (lowest) local cubeConfigTier = nil local function cubeTier(c) local t = c and c:FindFirstChild("Tier", true) if t and t:IsA("IntValue") then return t.Value end if not cubeConfigTier then cubeConfigTier = {} local rb = LocalPlayer.PlayerGui and LocalPlayer.PlayerGui:FindFirstChild("RecipeBook") local dc = rb and rb:FindFirstChild("DiscoveredCubes") if dc then for _, m in ipairs(dc:GetChildren()) do if m:IsA("Model") then local mt = m:FindFirstChild("Tier") if mt and mt:IsA("IntValue") then cubeConfigTier[m.Name] = mt.Value end end end end end return cubeConfigTier[c.Name] or 0 end -- gather a same-tier cluster of up to maxSize cubes from the pending list, -- nearest to ref first; returns the cluster and its average position local function buildCluster(pending, ref, maxSize, refPos, needTier) local cluster = { ref } local refTier = cubeTier(ref) local folder = Workspace:FindFirstChild("Cubes") for i = #pending, 1, -1 do if #cluster >= maxSize then break end local c = pending[i] if c.Parent == folder then local r = c:FindFirstChild("Root", true) if r and (not needTier or cubeTier(c) == refTier) and (r.Position - refPos).Magnitude <= 26 then table.insert(cluster, 1, c) table.remove(pending, i) end end end local mid = refPos if #cluster > 1 then local sum = Vector3.new(0, 0, 0) for _, c in ipairs(cluster) do local r = c:FindFirstChild("Root", true) if r then sum = sum + r.Position end end mid = sum / #cluster end return cluster, mid end -- shared "equip, drop at a CFrame, retry, wait for the tool to leave the hand" -- loop used by furnace smelt and cube purge; behavior is configured via opts local function dropHeldTools(opts) local done = 0 for _ = 1, opts.count() do if not opts.isRunning() then break end local tool = hasTool() if not tool or not opts.match(tool) then local nxt = opts.nextTool() if nxt then equipTool(nxt, opts.isRunning) end end tool = hasTool() if tool and opts.match(tool) then local dropCF = opts.makeDropCF() if opts.onBeforeDrop then opts.onBeforeDrop(done + 1) end opts.setLabel(done + 1) local prev = tool local fok, ferr = pcall(function() dropRemote:Fire(dropCF) end) diag(string.format(opts.fireDiag or "drop ok=%s err=%s", tostring(fok), tostring(ferr))) local t0 = os.clock() local retryAt = os.clock() + 0.7 local retries = 0 while os.clock() - t0 < 3 and opts.isRunning() do if hasTool() ~= prev then break end if retries < 2 and os.clock() >= retryAt then retries = retries + 1 if opts.retryMove then opts.retryMove() end pcall(function() dropRemote:Fire(dropCF) end) retryAt = os.clock() + 0.7 end task.wait(0.03) end if hasTool() == prev then unequipAll() break else done = done + 1 if opts.onSuccess then opts.onSuccess(dropCF) end end else break end end unequipAll() task.wait(0.05) diag((opts.doneDiag or "dropHeld: done=") .. done) return done end -- shared purge/bridge loop: gather clusters of cubes, then dispose them -- (into the void for purge, onto the bridge for bridge). opts picks the -- disposal target, the pending filter, the pickup wait and the labels. local function disposeRun(opts) local ok, err = pcall(function() diag(opts.prefix .. ": start") getRemotes() unequipAll() if opts.before then if opts.before() == false then return end end local nameSet = getFolderCubeNames() local folder = Workspace:FindFirstChild("Cubes") local done, failed = 0, 0 -- dispose whatever is already held first so those slots free up done = done + opts.dropHeld(nameSet) local cubes = getAllCubes() local total = #cubes diag(opts.prefix .. ": cubes=" .. total) if total == 0 then opts.setRunning(false) opts.setLabel(string.format(opts.doneWord .. " %d", done), false) resetLabelLater(opts.setLabel, opts.isRunning, opts.baseWord, 2) return end local pending = {} local reAttempted = {} for _, c in ipairs(cubes) do local r = c:FindFirstChild("Root", true) if opts.skip(r) then done = done + 1 else pending[#pending + 1] = c end end -- highest tier first table.sort(pending, function(a, b) return cubeTier(a) > cubeTier(b) end) while #pending > 0 and opts.isRunning() do -- the server only grants as many pickups as there are free inventory -- slots (backpack size minus what we already hold), so cap each cluster -- there instead of firing a fixed 5 and wasting the overflow local backpackSize = math.max(1, LocalPlayer:GetAttribute("BackpackSize") or 1) local freeSlots = math.max(0, backpackSize - countAllHeld()) if freeSlots <= 0 then -- full: dispose what we hold before grabbing more if countHeldNames(nameSet) > 0 then local dropped = opts.dropHeld(nameSet) done = done + dropped if dropped == 0 then failed = failed + 1 break end else failed = failed + 1 break end continue end local ref = pending[1] table.remove(pending, 1) local refRoot = ref:FindFirstChild("Root", true) if refRoot and folder and ref.Parent == folder then local cluster, mid = buildCluster(pending, ref, freeSlots, refRoot.Position, true) ensureNear(mid, 2) if not opts.isRunning() then break end opts.setLabel(string.format("Grab %d (%d left)", #cluster, #pending), true) local fired = 0 for _, c in ipairs(cluster) do if c.Parent == folder then pickUpRemote:Fire(c) fired = fired + 1 end end opts.waitPickups(cluster, folder) -- re-add any cube that never left the folder (pickup cap), retry once local gained = 0 for _, c in ipairs(cluster) do if c.Parent == folder then if reAttempted[c] then failed = failed + 1 else reAttempted[c] = true pending[#pending + 1] = c end else gained = gained + 1 end end opts.clusterDiag(cluster, fired, gained, freeSlots, countHeldNames(nameSet), #pending) local dropped = opts.dropHeld(nameSet) done = done + dropped else failed = failed + 1 end end if opts.isRunning() and countHeldNames(nameSet) > 0 then done = done + opts.dropHeld(nameSet) end unequipAll() opts.setRunning(false) if failed == 0 then opts.setLabel(string.format(opts.doneWord .. " %d", done), false) else opts.setLabel(string.format(opts.doneWord .. " %d/%d", done, total), false) end resetLabelLater(opts.setLabel, opts.isRunning, opts.baseWord, 3) opts.finishDiag(done, failed) teleportBack(opts.getOrigCF(), opts.prefix) end) if not ok then diag(opts.prefix .. ": ERROR " .. tostring(err)) diag("traceback:\n" .. tostring(debug.traceback())) opts.setRunning(false) opts.setLabel("Error", false) teleportBack(opts.getOrigCF()) resetLabelLater(opts.setLabel, opts.isRunning, opts.baseWord, 3) end end -- ============================ furnace auto-smelt ============================ local furnaceRunning = false local furnaceThread = nil local furnaceOrigCF = nil local FURNACE_DROP_CF = CFrame.new(-22.8, -2, 19.8) local FURNACE_STAND = Vector3.new(-18, -5, 19) local setFurnaceBtnLabel = makeLabelSetter(function() return furnaceBtn end, Color3.fromRGB(120, 60, 15), Color3.fromRGB(255, 235, 200)) local function smeltHeldDirt() if not dropRemote then diag("smeltHeldDirt: no dropRemote") return 0 end diag("smeltHeldDirt: start countDirt=" .. countDirt()) if countDirt() == 0 then diag("smeltHeldDirt: nothing held, skipping furnace teleport") return 0 end ensureNear(FURNACE_STAND, 12) diag("smeltHeldDirt: after ensureNear") if not furnaceRunning then diag("smeltHeldDirt: furnaceRunning false") return 0 end return dropHeldTools({ count = countDirt, match = function(t) return t.Name == "Dirt Cube" end, nextTool = function() local bp = LocalPlayer:FindFirstChildOfClass("Backpack") return bp and bp:FindFirstChild("Dirt Cube") or nil end, makeDropCF = function() return FURNACE_DROP_CF end, onBeforeDrop = function(n) diag("smeltHeldDirt: dropping " .. n) end, setLabel = function(n) setFurnaceBtnLabel(string.format("Drop %d", n), true) end, isRunning = function() return furnaceRunning end, retryMove = function() ensureNear(FURNACE_STAND, 8) end, fireDiag = "fire ok=%s err=%s", doneDiag = "smeltHeldDirt: done=", }) end local function furnaceRun() local ok, err = pcall(function() diag("furnaceRun: start") getRemotes() diag("furnaceRun: remotes ok dropRemote=" .. typeof(dropRemote) .. ":" .. (dropRemote and dropRemote.ClassName or "nil") .. " hasFire=" .. tostring(dropRemote and dropRemote.Fire ~= nil)) unequipAll() local folder = Workspace:FindFirstChild("Cubes") local done, failed = 0, 0 -- smelt whatever is already held (equipped + backpack) so those slots free up done = done + smeltHeldDirt() local cubes = getDirtCubes() local total = #cubes diag("furnaceRun: cubes=" .. total) if total == 0 then setFurnaceBtnLabel("No dirt cubes", false) furnaceRunning = false resetLabelLater(setFurnaceBtnLabel, function() return furnaceRunning end, "Smelt All", 2) return end local backpackSize = math.max(1, LocalPlayer:GetAttribute("BackpackSize") or 1) local pending = cubes while #pending > 0 and furnaceRunning do -- any tool in character or backpack takes a slot; never overfill local freeSlots = math.max(0, backpackSize - countAllHeld()) if freeSlots <= 0 then -- inventory full: smelt before gathering more if countDirt() > 0 then local smelted = smeltHeldDirt() done = done + smelted if smelted == 0 then failed = failed + 1 break end else -- slots are taken by non-dirt tools, cannot gather failed = failed + 1 break end continue end local batchSize = math.min(freeSlots, #pending) local ref = pending[1] table.remove(pending, 1) local refRoot = ref:FindFirstChild("Root", true) if refRoot and folder and ref.Parent == folder then local cluster, mid = buildCluster(pending, ref, batchSize, refRoot.Position, false) ensureNear(mid, 2) if not furnaceRunning then break end setFurnaceBtnLabel(string.format("Gather %d (%d left)", #cluster, #pending), true) local fired = 0 for _, c in ipairs(cluster) do if c.Parent == folder then pickUpRemote:Fire(c) fired = fired + 1 end end local heldBefore = countDirt() local t0 = os.clock() while os.clock() - t0 < 3.5 and furnaceRunning do if countDirt() >= heldBefore + fired then break end task.wait(0.05) end local gained = countDirt() - heldBefore local loaded = math.min(gained, fired) diag(string.format("gather cluster=%d fired=%d gained=%d heldAll=%d pending=%d", #cluster, fired, gained, countAllHeld(), #pending)) if loaded < fired then failed = failed + (fired - loaded) end else failed = failed + 1 end end if furnaceRunning and countDirt() > 0 then done = done + smeltHeldDirt() end unequipAll() furnaceRunning = false if failed == 0 then setFurnaceBtnLabel(string.format("Done %d", done), false) else setFurnaceBtnLabel(string.format("Done %d/%d", done, total), false) end resetLabelLater(setFurnaceBtnLabel, function() return furnaceRunning end, "Smelt All", 3) diag("furnaceRun: finished done=" .. done .. " failed=" .. failed) teleportBack(furnaceOrigCF) end) if not ok then diag("furnaceRun: ERROR " .. tostring(err)) diag("traceback:\n" .. tostring(debug.traceback())) furnaceRunning = false setFurnaceBtnLabel("Error", false) teleportBack(furnaceOrigCF) resetLabelLater(setFurnaceBtnLabel, function() return furnaceRunning end, "Smelt All", 3) end end local function onFurnacePressed() if furnaceRunning then furnaceRunning = false setFurnaceBtnLabel("Cancelling...", false) return end furnaceRunning = true setFurnaceBtnLabel("Starting...", true) local hrp = charHrp() furnaceOrigCF = hrp and hrp.CFrame or nil furnaceThread = task.spawn(furnaceRun) end local function createFurnaceButton() if furnaceBtn and furnaceBtn.Parent then return furnaceBtn end furnaceBtn = makeButton("FurnaceButton", Title, Vector2.new(1, 0.5), UDim2.new(1, -175, 0.5, 0), UDim2.new(0, 110, 0, 30), "Smelt All") connectSignal(furnaceBtn.Activated, onFurnacePressed) return furnaceBtn end createFurnaceButton() local researchedSignal = ServerSettings:GetAttributeChangedSignal("Researched") connectSignal(researchedSignal, refreshHighlights) -- ============================ cube purge ============================ local purgeRunning = false local purgeThread = nil local purgeOrigCF = nil local PURGE_VOID_Y = -15 -- drop spots where we already voided a cube this session: the server clamps -- the drop to the baseplate floor (~y=-5.5), which is above the -20 skip -- threshold, so without remembering where we dropped, the next purge run -- re-lists the same cubes and re-picks them forever local PURGE_SPOT_MATCH = 1.5 local purgedDropSpots = {} local setPurgeBtnLabel = makeLabelSetter(function() return purgeBtn end, Color3.fromRGB(120, 20, 20), Color3.fromRGB(255, 210, 210)) local function isPurgedSpot(pos) local x, z = pos.X, pos.Z for s, _ in pairs(purgedDropSpots) do local sx, sz = s:match("^(-?%d+%.?%d*)%|(-?%d+%.?%d*)$") if sx and sz then local dx, dz = tonumber(sx) - x, tonumber(sz) - z if dx * dx + dz * dz <= PURGE_SPOT_MATCH * PURGE_SPOT_MATCH then return true end end end return false end local function rememberPurgedSpot(pos) purgedDropSpots[string.format("%.1f|%.1f", pos.X, pos.Z)] = true end local function dropHeldCubesIntoVoid(nameSet) if not dropRemote then diag("purge: no dropRemote") return 0 end return dropHeldTools({ count = function() return countHeldNames(nameSet) end, match = function(t) return nameSet[t.Name] end, nextTool = function() return nextCubeTool(nameSet) end, makeDropCF = function() local hrp = charHrp() return hrp and CFrame.new(hrp.Position.X, PURGE_VOID_Y, hrp.Position.Z) or CFrame.new(0, PURGE_VOID_Y, 0) end, setLabel = function(n) setPurgeBtnLabel(string.format("Void %d", n), true) end, isRunning = function() return purgeRunning end, onSuccess = function(dropCF) rememberPurgedSpot(dropCF.Position) end, fireDiag = "purge drop ok=%s err=%s", doneDiag = "purge dropHeld: done=", }) end local function waitPurgePickups(cluster, folder) -- the server caps pickups to the backpack size; once the gained count -- stops growing for a moment, stop waiting instead of burning the timeout local t0 = os.clock() local lastGain = os.clock() local gainedCount = 0 while os.clock() - t0 < 3 and purgeRunning do local left = 0 for _, c in ipairs(cluster) do if c.Parent ~= folder then left = left + 1 end end if left >= #cluster then break end if left == gainedCount then if os.clock() - lastGain > 0.15 then break end else gainedCount = left lastGain = os.clock() end task.wait(0.05) end end local function purgeRun() disposeRun({ prefix = "purgeRun", doneWord = "Purged", baseWord = "Purge", isRunning = function() return purgeRunning end, setRunning = function(v) purgeRunning = v end, getOrigCF = function() return purgeOrigCF end, setLabel = setPurgeBtnLabel, dropHeld = dropHeldCubesIntoVoid, skip = function(r) -- skip cubes already below the islands (falling into the void) and -- any cube sitting on a spot we already voided this session return r and (r.Position.Y < -20 or isPurgedSpot(r.Position)) end, waitPickups = waitPurgePickups, clusterDiag = function(cluster, fired, gained, freeSlots, held, pendingLeft) diag(string.format("purge cluster=%d fired=%d gained=%d held=%d pending=%d", #cluster, fired, gained, held, pendingLeft)) end, finishDiag = function(done, failed) diag("purgeRun: finished purged=" .. done .. " failed=" .. failed) end, }) end local function onPurgePressed() if purgeRunning then purgeRunning = false setPurgeBtnLabel("Cancelling...", false) return end purgeRunning = true setPurgeBtnLabel("Starting...", true) local hrp = charHrp() purgeOrigCF = hrp and hrp.CFrame or nil purgeThread = task.spawn(purgeRun) end local function createPurgeButton() if purgeBtn and purgeBtn.Parent then return purgeBtn end purgeBtn = makeButton("PurgeButton", CoreFrame, Vector2.new(0, 1), UDim2.new(0, 8, 1, -24), UDim2.new(0, 90, 0, 30), "Purge") connectSignal(purgeBtn.Activated, onPurgePressed) return purgeBtn end createPurgeButton() -- ============================ bridge feed ============================ local bridgeRunning = false local bridgeThread = nil local bridgeOrigCF = nil local bridgeTargetBM = nil local lastBridgePos = nil local setBridgeBtnLabel = makeLabelSetter(function() return bridgeBtn end, Color3.fromRGB(20, 80, 20), Color3.fromRGB(210, 255, 210)) local function nearestBridgeBM(pos) local best, bestD local folder = Workspace:FindFirstChild("Bridges") if not folder then return nil end for _, bm in ipairs(folder:GetChildren()) do local bu = bm:FindFirstChild("BridgeUpgrade") if bu then local root = bu:FindFirstChild("Root") or bu:FindFirstChild("TouchPart") if root then local d = (root.Position - pos).Magnitude if not bestD or d < bestD then best, bestD = bm, d end end end end return best end local function currentBridgeFrontPos() if bridgeTargetBM and bridgeTargetBM.Parent then local bu = bridgeTargetBM:FindFirstChild("BridgeUpgrade") local tp = bu and bu:FindFirstChild("TouchPart") if tp then return tp.Position end end -- BU may have been replaced when the bridge advanced: re-pick the nearest one local refPos = lastBridgePos or (charHrp() and charHrp().Position or Vector3.new(0, 0, 0)) local nb = nearestBridgeBM(refPos) if nb then bridgeTargetBM = nb local bu = nb:FindFirstChild("BridgeUpgrade") local tp = bu and bu:FindFirstChild("TouchPart") if tp then return tp.Position end end return nil end local function dropHeldCubeOnBridge(nameSet) if not dropRemote then diag("bridge: no dropRemote") return false end local tool = hasTool() if not tool or not nameSet[tool.Name] then local nxt = nextCubeTool(nameSet) if not nxt then return false end equipTool(nxt, function() return bridgeRunning end) tool = hasTool() end if not tool or not nameSet[tool.Name] then return false end local front = currentBridgeFrontPos() if not front then diag("bridge: no BU touch") return false end -- the player stands above the hole, but the drop CFrame IS the white glowing -- consumer part (TouchPart) itself, so the item spawns directly on it -- regardless of size local dropPos = front local standPos = front + Vector3.new(0, 1.5, 0) -- teleport to the front; the settle lets the server register the position. -- short hops following the advancing front (already within a few studs) can -- drop instantly; long teleports need the settle or the first drop rejects. local hrpNow = charHrp() local far = hrpNow and (hrpNow.Position - dropPos).Magnitude > 15 ensureNear(standPos, 2, far and 0.15 or 0) if not bridgeRunning then return false end setBridgeBtnLabel("Feed", true) local prev = tool local function frontMoved(refPos) local f = currentBridgeFrontPos() if f and (f - refPos).Magnitude > 0.5 then return f end return nil end local dropped = false for attempt = 1, 3 do if not bridgeRunning then break end pcall(function() dropRemote:Fire(CFrame.new(dropPos)) end) -- wait for the front to advance, but detect rejection early: a consumed -- cube leaves our hand, a rejected one stays. Poll both signals. A -- success confirms within a few tenths, so a long silent window means -- rejection - declare it fast and let the retry re-fire. local t0 = os.clock() local newFront = nil local cubeGone = false while os.clock() - t0 < 0.45 and bridgeRunning do local f = frontMoved(front) if f then newFront = f break end if prev.Parent ~= LocalPlayer.Character then cubeGone = true break end task.wait(0.01) end if newFront then -- let the front settle before the next drop local t1 = os.clock() local last = newFront while os.clock() - t1 < 0.8 and bridgeRunning do task.wait(0.05) local f = currentBridgeFrontPos() if not f then break end if (f - last).Magnitude < 0.05 then newFront = f break end last = f end dropped = true ensureNear(newFront + Vector3.new(0, 1.5, 0), 2, 0) break end -- the front never advanced, but the cube left our hand: the drop DID -- land and the front is just slow to replicate. Wait longer for it. if cubeGone then local t2 = os.clock() local late = nil while os.clock() - t2 < 1.5 and bridgeRunning do local f = frontMoved(front) if f then late = f break end task.wait(0.02) end if late then dropped = true ensureNear(late + Vector3.new(0, 1.5, 0), 2, 0) else diag("bridge drop: cube gone but front never moved") end break end -- the cube is still in our hand: the drop was rejected. settle properly -- and retry the SAME cube. no second cube is fired, so nothing stacks. diag("bridge drop: attempt " .. attempt .. " rejected, retry") ensureNear(front + Vector3.new(0, 1.5, 0), 2, 0.25) if not bridgeRunning then break end front = currentBridgeFrontPos() or front dropPos = front standPos = front + Vector3.new(0, 1.5, 0) end if dropped then local f = currentBridgeFrontPos() lastBridgePos = f or front end unequipAll() return dropped end local function dropHeldCubesOnBridge(nameSet) local done = 0 while countHeldNames(nameSet) > 0 and bridgeRunning do if dropHeldCubeOnBridge(nameSet) then done = done + 1 else break end end unequipAll() task.wait(0.05) diag("bridge dropHeld: done=" .. done) return done end local function waitBridgePickups(cluster, folder) local t0 = os.clock() while os.clock() - t0 < 2 and bridgeRunning do local left = true for _, c in ipairs(cluster) do if c.Parent == folder then left = false break end end if left then break end task.wait(0.05) end end local function bridgeRun() disposeRun({ prefix = "bridgeRun", doneWord = "Fed", baseWord = "Bridge", isRunning = function() return bridgeRunning end, setRunning = function(v) bridgeRunning = v end, getOrigCF = function() return bridgeOrigCF end, setLabel = setBridgeBtnLabel, dropHeld = dropHeldCubesOnBridge, before = function() local hrp = charHrp() bridgeTargetBM = hrp and nearestBridgeBM(hrp.Position) or nil if not bridgeTargetBM then diag("bridgeRun: no bridge found") bridgeRunning = false setBridgeBtnLabel("No bridge", false) resetLabelLater(setBridgeBtnLabel, function() return bridgeRunning end, "Bridge", 2) return false end diag("bridgeRun: target " .. bridgeTargetBM.Name) return true end, skip = function(r) return r and r.Position.Y < -20 end, waitPickups = waitBridgePickups, clusterDiag = function(cluster, fired, gained, freeSlots, held, pendingLeft) diag(string.format("bridge cluster=%d fired=%d gained=%d held=%d freeSlots=%d pending=%d", #cluster, fired, gained, held, freeSlots, pendingLeft)) end, finishDiag = function(done, failed) diag("bridgeRun: finished fed=" .. done .. " failed=" .. failed) end, }) end local function onBridgePressed() if bridgeRunning then bridgeRunning = false setBridgeBtnLabel("Cancelling...", false) return end bridgeRunning = true setBridgeBtnLabel("Starting...", true) local hrp = charHrp() bridgeOrigCF = hrp and hrp.CFrame or nil bridgeThread = task.spawn(bridgeRun) end local function createBridgeButton() if bridgeBtn and bridgeBtn.Parent then return bridgeBtn end bridgeBtn = makeButton("FeedBridgeButton", CoreFrame, Vector2.new(0, 1), UDim2.new(0, 106, 1, -24), UDim2.new(0, 90, 0, 30), "Bridge") connectSignal(bridgeBtn.Activated, onBridgePressed) return bridgeBtn end createBridgeButton() -- ============================ window system ============================ local MIN_W, MIN_H = 500, 380 local IN = 10 local CORNER = 22 local function currentViewport() local cam = Workspace.CurrentCamera if cam then return cam.ViewportSize end return Vector2.new(1280, 720) end local function guiOrigin() local gui = CoreFrame.Parent if gui and gui:IsA("ScreenGui") then return gui.AbsolutePosition end return Vector2.new(0, 0) end local handles = {} local drag = nil local savedLayout local saveThread = nil local lockTimer = nil local lockWindow = false local SAVE_FILE = "PortableRecipeBook.json" local function loadLayout() if not isfile(SAVE_FILE) then return nil end local ok, raw = pcall(readfile, SAVE_FILE) if not ok then return nil end local ok2, decoded = pcall(HttpService.JSONDecode, HttpService, raw) if not ok2 or type(decoded) ~= "table" then return nil end local x, y, w, h = decoded.x, decoded.y, decoded.w, decoded.h if type(x) == "number" and type(y) == "number" and type(w) == "number" and type(h) == "number" then return { x = x, y = y, w = w, h = h } end return nil end local function flushSave() if saveThread then task.cancel(saveThread) saveThread = nil end if savedLayout then pcall(writefile, SAVE_FILE, HttpService:JSONEncode(savedLayout)) end end local function scheduleSave() if saveThread then task.cancel(saveThread) end saveThread = task.delay(0.3, function() saveThread = nil flushSave() end) end savedLayout = (typeof(STATE) == "table" and STATE.savedLayout) or loadLayout() local function setFrameRect(x, y, w, h) savedLayout = { x = x, y = y, w = w, h = h } if typeof(STATE) == "table" then STATE.savedLayout = savedLayout end CoreFrame.Position = UDim2.new(0, x, 0, y) CoreFrame.Size = UDim2.new(0, w, 0, h) scheduleSave() end local function clampRect(x, y, w, h) local vp = currentViewport() local o = guiOrigin() local sx = math.clamp(x + o.X, 0, math.max(0, vp.X - w)) local sy = math.clamp(y + o.Y, 0, math.max(0, vp.Y - h)) return sx - o.X, sy - o.Y end local function frameOrigin() local o = guiOrigin() return CoreFrame.AbsolutePosition.X - o.X, CoreFrame.AbsolutePosition.Y - o.Y end local function makeHandle(name, pos, size, sx, sy, px, py, z) local f = Instance.new("Frame") f.Name = "ResizeHandle_" .. name f.Active = true f.AnchorPoint = Vector2.new(0, 0) f.Position = pos f.Size = size f.BackgroundColor3 = Color3.fromRGB(205, 205, 215) f.BackgroundTransparency = 1 f.BorderSizePixel = 0 f.ZIndex = z or 200 f.Parent = CoreFrame local h = { name = name, frame = f, sx = sx, sy = sy, px = px, py = py } handles[name] = h return h end local function createResizeHandles() makeHandle("TL", UDim2.new(0, 0, 0, 0), UDim2.new(0, CORNER, 0, CORNER), -1, -1, 1, 1, 210) makeHandle("TR", UDim2.new(1, -CORNER, 0, 0), UDim2.new(0, CORNER, 0, CORNER), 1, -1, 0, 1, 210) makeHandle("BL", UDim2.new(0, 0, 1, -CORNER), UDim2.new(0, CORNER, 0, CORNER), -1, 1, 1, 0, 210) makeHandle("BR", UDim2.new(1, -CORNER, 1, -CORNER), UDim2.new(0, CORNER, 0, CORNER), 1, 1, 0, 0, 210) makeHandle("T", UDim2.new(0, 0, 0, 0), UDim2.new(1, 0, 0, IN), 0, -1, 0, 1) makeHandle("B", UDim2.new(0, 0, 1, -IN), UDim2.new(1, 0, 0, IN), 0, 1, 0, 0) makeHandle("L", UDim2.new(0, 0, 0, 0), UDim2.new(0, IN, 1, 0), -1, 0, 1, 0) makeHandle("R", UDim2.new(1, -IN, 0, 0), UDim2.new(0, IN, 1, 0), 1, 0, 0, 0) end local MOVE = { name = "move", sx = 0, sy = 0, px = 1, py = 1 } local function beginDrag(target) if not CoreFrame.Visible or drag then return end local ox, oy = frameOrigin() drag = { name = target.name, sx = target.sx, sy = target.sy, px = target.px, py = target.py, x = ox, y = oy, w = CoreFrame.AbsoluteSize.X, h = CoreFrame.AbsoluteSize.Y, mouse = UIS:GetMouseLocation(), } end local function dragLoop() while drag do local m = UIS:GetMouseLocation() local dx = m.X - drag.mouse.X local dy = m.Y - drag.mouse.Y local x0, y0, w0, h0 = drag.x, drag.y, drag.w, drag.h local vp = currentViewport() local w = math.clamp(w0 + dx * drag.sx, MIN_W, vp.X) local h = math.clamp(h0 + dy * drag.sy, MIN_H, vp.Y) local x, y if drag.sx == 0 and drag.sy == 0 then x = x0 + dx y = y0 + dy else if drag.px == 1 then x = x0 + (w0 - w) else x = x0 end if drag.py == 1 then y = y0 + (h0 - h) else y = y0 end end x, y = clampRect(x, y, w, h) setFrameRect(x, y, w, h) task.wait() end end local function onHandleInput(target) return function(input) if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end beginDrag(target) if drag then task.spawn(dragLoop) end end end local function onTitleInput(input) if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end beginDrag(MOVE) if drag then task.spawn(dragLoop) end end local function onInputEnded(input) if input.UserInputType == Enum.UserInputType.MouseButton1 then if drag then flushSave() end drag = nil end end local function applyLayoutImmediate() if not savedLayout then return end local x, y = clampRect(savedLayout.x, savedLayout.y, savedLayout.w, savedLayout.h) CoreFrame.Position = UDim2.new(0, x, 0, y) CoreFrame.Size = UDim2.new(0, savedLayout.w, 0, savedLayout.h) end local function onPositionChanged() if lockWindow and CoreFrame.Visible and savedLayout then local x, y = clampRect(savedLayout.x, savedLayout.y, savedLayout.w, savedLayout.h) CoreFrame.Position = UDim2.new(0, x, 0, y) end end local function onSizeChanged() if lockWindow and CoreFrame.Visible and savedLayout then CoreFrame.Size = UDim2.new(0, savedLayout.w, 0, savedLayout.h) end end local function onVisibleChanged() if CoreFrame.Visible then pcall(function() local tweens = TweenService:GetTweens(CoreFrame) if tweens then for _, t in ipairs(tweens) do t:Cancel() end end end) applyLayoutImmediate() lockWindow = true if lockTimer then task.cancel(lockTimer) end lockTimer = task.delay(0.8, function() lockWindow = false lockTimer = nil end) else lockWindow = false end end local aspectClone local function setupWindow() local aspect = CoreFrame:FindFirstChildOfClass("UIAspectRatioConstraint") if aspect then aspectClone = aspect:Clone() aspect:Destroy() end CoreFrame.AnchorPoint = Vector2.new(0, 0) CoreFrame.Draggable = false createResizeHandles() for _, name in ipairs({ "TL", "TR", "BL", "BR", "T", "B", "L", "R" }) do local h = handles[name] connectSignal(h.frame.InputBegan, onHandleInput(h)) end connectSignal(Title.InputBegan, onTitleInput) connectSignal(UIS.InputEnded, onInputEnded) connectSignal(CoreFrame:GetPropertyChangedSignal("Visible"), onVisibleChanged) connectSignal(CoreFrame:GetPropertyChangedSignal("Position"), onPositionChanged) connectSignal(CoreFrame:GetPropertyChangedSignal("Size"), onSizeChanged) connectSignal(CoreFrame.DescendantAdded, function(d) if d:IsA("UIAspectRatioConstraint") then d:Destroy() end end) end setupWindow() if CoreFrame.Visible and savedLayout then applyLayoutImmediate() end -- ============================ cleanup ============================ if typeof(STATE) == "table" and type(STATE.onCleanup) == "function" then STATE.onCleanup(function() stopHighlighting() if saveThread then task.cancel(saveThread) saveThread = nil end flushSave() if lockTimer then task.cancel(lockTimer) lockTimer = nil end lockWindow = false if highlightBtn then pcall(function() highlightBtn:Destroy() end) end furnaceRunning = false if furnaceThread then task.cancel(furnaceThread) furnaceThread = nil end if furnaceBtn then pcall(function() furnaceBtn:Destroy() end) end purgeRunning = false if purgeThread then task.cancel(purgeThread) purgeThread = nil end if purgeBtn then pcall(function() purgeBtn:Destroy() end) end bridgeRunning = false if bridgeThread then task.cancel(bridgeThread) bridgeThread = nil end if bridgeBtn then pcall(function() bridgeBtn:Destroy() end) end drag = nil for _, h in pairs(handles) do pcall(function() h.frame:Destroy() end) end CoreFrame.AnchorPoint = Vector2.new(0.5, 0.5) if aspectClone and not CoreFrame:FindFirstChildOfClass("UIAspectRatioConstraint") then aspectClone.Parent = CoreFrame end end) end print("PortableRecipeBook: loaded, press G to open/close, book is draggable + resizable, Unresearched toggle + Smelt All button in the bar, Purge + Bridge buttons bottom-left")