AUTOFARM | By Project UAI
Key Required

AUTOFARM | By Project UAI

Legends Of Speed ⚡UltDEVUltDEV 967 views 4.6K executesUpdated Sep 21, 2026
Unrated
00
Lua Script•2205 lines (81849 chars)
-- =====================================================================
--   MADE BY deepseek-v4-flash INSIDE PROJECT UAI
--   Legends of Speed Ultimate Autofarm v5
-- =====================================================================
--
--   >> UI LIB EDITION  (file: los_autofarm_v5_ui.lua)
--   Same v5 farm core; the hand-built HUD is replaced by the Project
--   UAI UI LIB window from
--   https://raw.githubusercontent.com/Project-Ptolemy/ProjectUAI
--   los_autofarm_v5.lua itself is untouched.
--
-- v5 was written against the LIVE place 3101667897 on 2026-09-21, using the
-- decompiled client scripts (Potassium 2.4.9) instead of guesswork.
--
-- WHAT v4 GOT WRONG, AND WHAT v5 DOES INSTEAD (all measured in-game)
-- ---------------------------------------------------------------------
-- * EGG HATCHING WAS 50x TOO SLOW AND SILENTLY DEAD. v4 held E for 0.8s beside
--   the crystal and hopped 34 studs out and back, giving ~1 hatch per 7s. The
--   real call the game makes is one line inside crystalOpenScript:
--       openCrystalRemote:InvokeServer("openCrystal", "<Name> Crystal")
--   v5 calls it directly. No menu, no E, no movement, no proximity: the client's
--   30-stud check lives in crystalOpenScript.openOneCrystal and the server does
--   not repeat it (verified hatching from 400+ studs away).
--   MEASURED v5: 4.4-9.4 hatches/s, zero failures.
-- * HATCHING DIES AT FULL CAPACITY. openOneCrystal refuses when
--   calculatePetCapacity(petsFolder) or calculateTrailCapacity(trailsFolder)
--   reaches maxPetCapacity, and the server refuses too (0 gems spent, nil back).
--   v4 reported "crystal busy" forever. v5 adds a SELL ENGINE that keeps N free
--   slots, so hatching never stalls.
-- * NOTHING WAS EVER SOLD OR EQUIPPED. Real shapes, read from the GUI scripts:
--       sellPetEvent:FireServer("sellPet", petInstance)
--       sellTrailEvent:FireServer("sellTrail", trailInstance)
--       equipPetEvent:FireServer("equipPet" / "unequipPet", petInstance)
--       petEvolveEvent:FireServer("evolvePet", pet.Name)
--       evolveTrailEvent:FireServer("evolveTrail", trailInstance)
--       rebirthEvent:FireServer("rebirthRequest")
--       rEvents.ultimatesRemote:InvokeServer("upgradeUltimate", "<name>")
--       rEvents.checkChestRemote:InvokeServer("<Chest Name>")
--   Sell value is globalFunctions.calculatePetValue(tier, level, evolved).
-- * THE DROP TABLES ARE PUBLIC. ReplicatedStorage.crystalChances lists every
--   crystal's pool with chanceValue/nameValue/rarityValue, and cPetShopFolder
--   prices every pet and trail. v5 reads both, so it can snipe a named pet
--   ("Swift Samurai", Jungle Crystal, 1%) instead of hatching blind.
--   Jungle Crystal: Swift Samurai Omega 1%, Golden Viking Unique 20%,
--   Speedy Sensei Unique 14%, Maestro Dog Unique 35%, Divine Pegasus Epic 30%.
--   Electro Legends Crystal: six Omegas, 2-30% each (no rare ones).
-- * ORB FARM, HOOP LOOP, QUESTS were already right in v4 and are unchanged.
--   orbEvent:FireServer("collectOrb", orbName, currentMap.Value) is exactly what
--   oScript fires; hoopEvent is server->client only, so relocating Workspace.Hoops
--   onto the character is still the only lever.
--
-- NEW IN v5: sell engine (ranked by real pet/trail prices, never sells equipped),
-- auto-equip that frees a slot first, pet sniper with live chance math, auto
-- rebirth, auto ultimate upgrades, chest collector, session income stats.
-- =====================================================================

local Players = game:GetService("Players")
local player = Players.LocalPlayer
local RS = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local HttpService = game:GetService("HttpService")

local rEvents = RS:WaitForChild("rEvents", 30)
local function remote(name)
    local r = rEvents and rEvents:FindFirstChild(name)
    return r
end
local orbEvent = remote("orbEvent")
local questsEvent = remote("questsEvent")
local openCrystalRemote = remote("openCrystalRemote")
local sellPetEvent = remote("sellPetEvent")
local sellTrailEvent = remote("sellTrailEvent")
local equipPetEvent = remote("equipPetEvent")
local petEvolveEvent = remote("petEvolveEvent")
local evolveTrailEvent = remote("evolveTrailEvent")
local rebirthEvent = remote("rebirthEvent")
local speedRampEvent = remote("speedRampEvent")
local ultimatesRemote = remote("ultimatesRemote")
local checkChestRemote = remote("checkChestRemote")

local GF = nil
pcall(function() GF = require(RS:WaitForChild("globalFunctions")) end)

local MONO = {
    bg = Color3.fromRGB(18, 18, 20),
    panel = Color3.fromRGB(26, 26, 30),
    panel2 = Color3.fromRGB(34, 34, 40),
    border = Color3.fromRGB(48, 48, 56),
    text = Color3.fromRGB(235, 235, 240),
    sub = Color3.fromRGB(150, 150, 160),
    accent = Color3.fromRGB(200, 200, 210),
    success = Color3.fromRGB(120, 255, 160),
    warn = Color3.fromRGB(255, 200, 90),
    danger = Color3.fromRGB(255, 90, 90),
    rare = Color3.fromRGB(180, 140, 255),
}

local state = {
    -- hatch
    hatcher = false, hatchThread = nil, hatchChoice = "Jungle",
    hatchRate = 8, hatchMax = 0, gemReserve = 0,
    autoSell = true, keepTop = 6, keepFree = 3,
    snipePet = "", hatches = 0, rarity = {}, hits = {},
    -- sell / equip
    autoEquip = false, equipThread = nil,
    -- other loops
    orbFarming = false, orbChoice = nil, orbRate = 1200, orbAmount = 250000,
    orbInfinite = true, orbThread = nil,
    hoopLoop = false, hoopThread = nil,
    quest = false, questThread = nil,
    evolvePets = false, petThread = nil,
    evolveTrails = false, trailThread = nil,
    ramps = false, rampThread = nil,
    autoRebirth = false, rebirthThread = nil, rebirthsDone = 0, rebirthReserve = 3,
    autoUltimates = false, ultimateThread = nil, ultimatesBought = 0,
    chests = false, chestThread = nil, chestsOpened = 0,
    keepNames = {},
    minimised = false, session = {},
}

-- ============ INSTANCE GUARD ============
local GENV = _G
pcall(function()
    if type(getgenv) == "function" then GENV = getgenv() end
end)
local INSTANCE_ID = (GENV.__LOS_AUTOFARM_ID or 0) + 1
GENV.__LOS_AUTOFARM_ID = INSTANCE_ID
local function isCurrent() return GENV.__LOS_AUTOFARM_ID == INSTANCE_ID end

-- replaced with the real thing once the GUI exists (declared here so every loop
-- below can report through it)
local setStatus = function() end
-- toast, replaced once the GUI exists
local notify = function() end

-- names that must never be sold (filled from the GUI / API)
state.keepNames = {}

local function spawnLoop(fn)
    local co = coroutine.create(function()
        local ok, err = pcall(fn)
        if not ok then warn("[LOS Autofarm v5] loop error: " .. tostring(err)) end
    end)
    local ok, err = coroutine.resume(co)
    if not ok then warn("[LOS Autofarm v5] loop error: " .. tostring(err)) end
    return co
end

-- ============ LIVE GAME DATA ============
local function shorten(n)
    n = tonumber(n) or 0
    local s = { "", "K", "M", "B", "T", "Qa", "Qi" }
    local i = 1
    while n >= 1000 and i < #s do n = n / 1000; i = i + 1 end
    return i == 1 and tostring(math.floor(n)) or string.format("%.2f%s", n, s[i])
end

local function statValue(name)
    local v = player:FindFirstChild(name)
    if v and v:IsA("ValueBase") then return v.Value end
    local ls = player:FindFirstChild("leaderstats")
    v = ls and ls:FindFirstChild(name)
    return v and v.Value or 0
end

local function shadowNum(v) return tonumber(v) or 0 end
local function gems() return shadowNum(player:FindFirstChild("Gems") and player.Gems.Value) end
local function level() return shadowNum(player:FindFirstChild("level") and player.level.Value) end
local function rebirths() return shadowNum(statValue("Rebirths")) end
local function cap()
    local c = player:FindFirstChild("maxPetCapacity")
    return c and c.Value or 20
end

-- every crystal, cheapest first, with its real drop pool
local crystals = {}
do
    local prices = RS:FindFirstChild("crystalPrices")
    local chances = RS:FindFirstChild("crystalChances")
    if prices then
        for _, c in ipairs(prices:GetChildren()) do
            local base = c.Name:gsub(" Crystal$", "")
            local p, t = c:FindFirstChild("price"), c:FindFirstChild("priceType")
            local entry = { base = base, price = (p and p.Value) or math.huge, ptype = (t and t.Value) or "Gems", pool = {} }
            local ch = chances and chances:FindFirstChild(c.Name)
            if ch then
                for _, item in ipairs(ch:GetChildren()) do
                    local n = item:FindFirstChild("nameValue")
                    local r = item:FindFirstChild("rarityValue")
                    local cv = item:FindFirstChild("chanceValue")
                    if n then
                        table.insert(entry.pool, { name = n.Value, rarity = r and r.Value or "?", chance = (cv and cv.Value) or 0 })
                    end
                end
                table.sort(entry.pool, function(a, b) return a.chance < b.chance end)
            end
            table.insert(crystals, entry)
        end
        table.sort(crystals, function(a, b) return a.price < b.price end)
    end
end

local function crystalByName(base)
    if base == nil then return nil end
    base = tostring(base):gsub("%s*Crystal%s*$", "")
    for _, c in ipairs(crystals) do
        if c.base == base or (c.base .. " Crystal") == base then return c end
    end
    return nil
end
local function crystalLabel(c)
    local parts = {}
    for _, p in ipairs(c.pool) do
        table.insert(parts, string.format("%s %s %d%%", p.name, p.rarity, p.chance))
    end
    return table.concat(parts, ", ")
end

-- real pet/trail prices: cPetShopFolder prices every pet and trail
local priceOf = {}
do
    local shop = RS:FindFirstChild("cPetShopFolder")
    if shop then
        for _, item in ipairs(shop:GetChildren()) do
            local p = item:FindFirstChild("priceValue")
            local r = item:FindFirstChild("rarityValue")
            if p and not priceOf[item.Name] then
                priceOf[item.Name] = { price = p.Value, rarity = r and r.Value or "?" }
            end
        end
    end
end

local orbs = {}      -- orb name -> { stat = "Steps"/"Gems", base = n }
do
    local folder = RS:FindFirstChild("Orbs")
    if folder then
        for _, m in ipairs(folder:GetChildren()) do
            local v = m:FindFirstChildOfClass("IntValue") or m:FindFirstChildOfClass("NumberValue")
            if v then orbs[m.Name] = { stat = v.Name, base = v.Value } end
        end
    end
end

-- ============ ITEM HELPERS ============
local function folders()
    return {
        pets = player:FindFirstChild("petsFolder"),
        trails = player:FindFirstChild("trailsFolder"),
    }
end

local function itemRows(kind)
    local f = folders()[kind]
    local rows = {}
    if not f then return rows end
    for _, tier in ipairs(f:GetChildren()) do
        for _, it in ipairs(tier:GetChildren()) do
            rows[#rows + 1] = { it = it, tier = tier.Name, name = it.Name, kind = kind }
        end
    end
    return rows
end

local function itemCount(kind)
    local f = folders()[kind]
    local n = 0
    if not f then return 0 end
    for _, tier in ipairs(f:GetChildren()) do n = n + #tier:GetChildren() end
    return n
end

-- what selling an item actually pays, level and evolution included
local function sellValueOf(row)
    if not GF then return 0 end
    local lvl = row.it:FindFirstChild("level")
    return GF.calculatePetValue(row.tier, lvl and lvl.Value or 1, row.it:FindFirstChild("evolved") ~= nil)
end

local function equippedSet()
    local set = {}
    local eq = player:FindFirstChild("equippedPets")
    if eq then
        for _, c in ipairs(eq:GetChildren()) do
            local ref = c:FindFirstChild("petReference")
            if ref and ref.Value then set[ref.Value] = true end
        end
    end
    return set
end

local function equippedTrail()
    local t = player:FindFirstChild("equippedTrail")
    return t and t.Value or nil
end

-- ============ SELL / KEEP POLICY ============
-- Every sell decision goes through buildKeepSet, so "what gets sold" is one
-- readable rule instead of scattered conditions:
--   1. NEVER sell  the equipped pets, the equipped trail, or anything named in
--      state.keepNames.
--   2. NEVER sell  evolved pets/trails (state.keepEvolved) - each one cost five
--      non-evolved copies, verified below.
--   3. ALWAYS sell anything below the rarity floor (state.sellBelow) unless it is
--      hard-protected (equipped, named in keepNames, or evolved with keepEvolved
--      on) - a floor is for dumping fodder, not for destroying a kept pet.
--   4. KEEP UP TO state.keepDupes copies of a name in state.keepNames - that is
--      evolve fodder, and selling it is what silently kills auto-evolve.
--   5. KEEP the best state.keepTop items by the selected ranking.
--   6. Sell the lowest-ranked survivors, worst first, when a slot is needed.
local RARITY_ORDER = { Basic = 1, Advanced = 2, Rare = 3, Epic = 4, Unique = 5, Omega = 6 }
local sellStats = { pets = 0, trails = 0, gems = 0 }

state.rankMode = state.rankMode or "MARKET"
state.sellBelow = state.sellBelow or "Basic"
if state.keepEvolved == nil then state.keepEvolved = true end
state.keepDupes = state.keepDupes or 5
if state.evolveAll == nil then state.evolveAll = true end
if state.autoEvolve == nil then state.autoEvolve = false end

-- ranking is selectable, because "best" means different things here:
--   MARKET  = ReplicatedStorage.cPetShopFolder priceValue (rarity/desirability)
--   VALUE   = what selling it actually pays, globalFunctions.calculatePetValue
--   RARITY  = tier first (Omega > Unique > Epic > Rare > Advanced > Basic)
local function marketOf(row)
    local p = priceOf[row.name]
    return p and p.price or 0
end
local function rarityOf(row)
    local p = priceOf[row.name]
    return p and p.rarity or "Basic"
end
local function rankOf(row)
    if state.rankMode == "VALUE" then return sellValueOf(row) end
    if state.rankMode == "RARITY" then
        return (RARITY_ORDER[rarityOf(row)] or 0) * 1000000 + sellValueOf(row)
    end
    return marketOf(row)
end

local function isEvolved(row)
    local e = row.it:FindFirstChild("evolved")
    return e ~= nil and e.Value == true
end

local function isProtected(row)
    if row.kind == "pets" and equippedSet()[row.it] then return true end
    if row.kind == "trails" and equippedTrail() == row.it then return true end
    if state.keepNames[row.name] then return true end
    return false
end

-- worst first, so index 1 is the next candidate to sell
local function rankedWorstFirst(kind)
    local rows = itemRows(kind)
    table.sort(rows, function(a, b)
        local ra, rb = rankOf(a), rankOf(b)
        if ra == rb then return sellValueOf(a) < sellValueOf(b) end
        return ra < rb
    end)
    return rows
end

local function buildKeepSet(kind)
    local rows = rankedWorstFirst(kind)
    local keep, dupes = {}, {}
    local topKept = 0
    local floor = RARITY_ORDER[state.sellBelow] or 0
    for i = #rows, 1, -1 do                     -- best first
        local row = rows[i]
        local protected = isProtected(row)
        local evo = isEvolved(row)
        local belowFloor = (RARITY_ORDER[rarityOf(row)] or 0) < floor
        if protected or (state.keepEvolved and evo) then
            keep[row.it] = true
        elseif belowFloor then
            -- forced out, never kept (step 3)
        elseif state.keepNames[row.name] then
            dupes[row.name] = dupes[row.name] or 0
            if dupes[row.name] < state.keepDupes then
                dupes[row.name] = dupes[row.name] + 1
                keep[row.it] = true
            end
        elseif topKept < state.keepTop then
            topKept = topKept + 1
            keep[row.it] = true
        end
    end
    return keep
end

local function nextToSell(kind)
    local keep = buildKeepSet(kind)
    for _, row in ipairs(rankedWorstFirst(kind)) do
        if not keep[row.it] then return row end
    end
    return nil
end

-- what the policy would sell next, for the GUI (answers "what gets auto sold")
local function sellPreview(kind, n)
    local keep = buildKeepSet(kind)
    local out = {}
    for _, row in ipairs(rankedWorstFirst(kind)) do
        if not keep[row.it] then
            table.insert(out, string.format("%s (%s, %d)", row.name, rarityOf(row), sellValueOf(row)))
            if #out >= (n or 3) then break end
        end
    end
    return out
end

local function sellRow(row)
    if row.kind == "pets" then
        if sellPetEvent then sellPetEvent:FireServer("sellPet", row.it) end
        sellStats.pets = sellStats.pets + 1
    else
        if sellTrailEvent then sellTrailEvent:FireServer("sellTrail", row.it) end
        sellStats.trails = sellStats.trails + 1
    end
    return true
end

local function sellLowest(kind)
    local row = nextToSell(kind)
    if not row then return nil end
    sellRow(row)
    return row
end

-- sell a freshly hatched item unless the policy says it is a keeper
local function sellIfJunk(row)
    if not state.autoSell or not row then return false end
    local keep = buildKeepSet(row.kind)
    if keep[row.it] then return false end
    local g0 = gems()
    sellRow(row)
    sellStats.gems = sellStats.gems + math.max(gems() - g0, 0)
    return true
end

-- keep `state.keepFree` slots free on both pets and trails
local function ensureRoom(need)
    need = need or state.keepFree
    local freed = 0
    local capNow = cap()
    for _ = 1, 30 do
        if capNow - itemCount("pets") >= need then break end
        if not sellLowest("pets") then break end
        freed = freed + 1
        task.wait(0.08)
    end
    for _ = 1, 30 do
        if capNow - itemCount("trails") >= need then break end
        if not sellLowest("trails") then break end
        freed = freed + 1
        task.wait(0.08)
    end
    return freed
end

-- ============ EVOLVE ENGINE ============
-- Verified live: evolving needs FIVE non-evolved copies of the same name, and the
-- server consumes four of them, leaving one marked evolved.
--   pets : petEvolveEvent:FireServer("evolvePet", petName)
--   trails: evolveTrailEvent:FireServer("evolveTrail", trailInstance)
-- Measured: 5x Red Bunny (1,500 gems each from the pet shop) -> pets 14 -> 10 with
-- one evolved Red Bunny left; 5x Red Trail -> trails 19 -> 15 with one evolved.
-- globalFunctions.getNumberOfPets / getNumberOfSameTrail count only NON-evolved
-- copies, which is exactly the number the /5 gate wants.
local evolveStats = { pets = 0, trails = 0, last = {} }

local function evolveCounts(kind)
    local counts = {}
    for _, row in ipairs(itemRows(kind)) do
        if not isEvolved(row) then
            counts[row.name] = (counts[row.name] or 0) + 1
        end
    end
    return counts
end

local function evolveScan(kind, onlyKeepList)
    local counts = evolveCounts(kind)
    local done = {}
    local names = {}
    for name, n in pairs(counts) do
        if n >= 5 then table.insert(names, name) end
    end
    table.sort(names)
    for _, name in ipairs(names) do
        if state.evolveAll or not onlyKeepList or state.keepNames[name] then
            if kind == "pets" then
                if petEvolveEvent then petEvolveEvent:FireServer("evolvePet", name) end
                evolveStats.pets = evolveStats.pets + 1
            else
                -- trails take the instance, so hand over a non-evolved copy
                for _, row in ipairs(itemRows("trails")) do
                    if row.name == name and not isEvolved(row) then
                        if evolveTrailEvent then evolveTrailEvent:FireServer("evolveTrail", row.it) end
                        break
                    end
                end
                evolveStats.trails = evolveStats.trails + 1
            end
            evolveStats.last[kind .. "/" .. name] = (evolveStats.last[kind .. "/" .. name] or 0) + 1
            table.insert(done, name)
            task.wait(0.9)
        end
    end
    return done
end

local function evolveNow()
    local a = evolveScan("pets")
    local b = evolveScan("trails")
    return a, b
end

local function evolveLoop()
    while (state.autoEvolve or state.evolvePets or state.evolveTrails) and isCurrent() do
        local fired = {}
        if state.autoEvolve or state.evolvePets then
            for _, n in ipairs(evolveScan("pets")) do table.insert(fired, "pet " .. n) end
        end
        if state.autoEvolve or state.evolveTrails then
            for _, n in ipairs(evolveScan("trails")) do table.insert(fired, "trail " .. n) end
        end
        if #fired > 0 then
            setStatus("Evolved: " .. table.concat(fired, ", "), MONO.rare)
            notify("Evolved " .. #fired .. " item type(s)", "good")
        else
            local ready = {}
            for kind, label in pairs({ pets = "pets", trails = "trails" }) do
                local best, bestName = 0, nil
                for name, n in pairs(evolveCounts(kind)) do
                    if n > best and n >= 2 then best, bestName = n, name end
                end
                if bestName then
                    ready[#ready + 1] = string.format("%s %s %d/5", label, bestName, best)
                end
            end
            setStatus(#ready > 0 and ("Evolve watch: " .. table.concat(ready, ", ")) or "Evolve watch: no duplicates yet", MONO.sub)
        end
        task.wait(3)
    end
    state.autoEvolve, state.evolvePets, state.evolveTrails = false, false, false
    state.evolveThread = nil
end

-- ============ EQUIP ENGINE ============
-- The game refuses an equip when every slot is taken (findEmptyPetSlot == nil),
-- so v5 unequips the weakest equipped pet first, then equips. Verified: equipping
-- into a freed slot lands (pet1 went from Unique/Speedy Sensei to Omega/Soul Fusion Dog).
local equipStats = { equipped = 0, unequipped = 0 }

local function drainTotals()
    local t = 0
    local rows = itemRows("pets")
    for _, r in ipairs(rows) do t = t + sellValueOf(r) end
    return t
end

local function autoEquipStep()
    local eq = player:FindFirstChild("equippedPets")
    if not eq then return 0 end
    local slots = #eq:GetChildren()
    if slots == 0 then slots = 4 end
    local rows = itemRows("pets")
    if #rows == 0 then return 0 end
    table.sort(rows, function(a, b)
        local ra, rb = rankOf(a), rankOf(b)
        if ra == rb then return sellValueOf(a) > sellValueOf(b) end
        return ra > rb
    end)
    local have = equippedSet()
    local actions = 0
    local wanted = math.min(slots, #rows)
    for i = 1, wanted do
        local row = rows[i]
        if not have[row.it] then
            -- free a slot if the list is full
            local occupied = 0
            for _ in ipairs(eq:GetChildren()) do occupied = occupied + 1 end
            if occupied >= slots then
                -- weak link: lowest rank that is currently equipped
                local worst
                for _, c in ipairs(eq:GetChildren()) do
                    local ref = c:FindFirstChild("petReference")
                    if ref and ref.Value then
                        local rk = priceOf[ref.Value.Name] and priceOf[ref.Value.Name].price or 0
                        if not worst or rk < worst.rank then worst = { ref = ref, rank = rk } end
                    end
                end
                if worst and worst.rank < rankOf(row) and equipPetEvent then
                    equipPetEvent:FireServer("unequipPet", worst.ref.Value)
                    equipStats.unequipped = equipStats.unequipped + 1
                    task.wait(0.35)
                else
                    break
                end
            end
            if equipPetEvent then
                equipPetEvent:FireServer("equipPet", row.it)
                equipStats.equipped = equipStats.equipped + 1
                actions = actions + 1
                task.wait(0.35)
            end
        end
    end
    return actions
end

local function equipLoop()
    while state.autoEquip and isCurrent() do
        local n = autoEquipStep()
        setStatus(n > 0 and ("Equipped " .. n .. " pet(s)") or "Equipped pets already optimal", MONO.accent)
        task.wait(5)
    end
    state.autoEquip = false
    state.equipThread = nil
end

-- ============ HATCH ENGINE (v5 flagship) ============
-- The whole game-side hatch is:
--   openCrystalRemote:InvokeServer("openCrystal", "<Name> Crystal")
-- Returns (petName, rarity, imageId, extra). Cost is crystalPrices[name].price in
-- Gems. No cooldown was measured; capacity (pets AND trails under maxPetCapacity)
-- is the only real gate, which is why the sell engine runs inside this loop.
local hatchStats = { attempts = 0, pets = 0, trails = 0, gems = 0, failed = 0 }

local function crystalFor(base)
    local c = crystalByName(base)
    if c then return c end
    return crystals[#crystals]
end

-- most expensive crystal we can still afford above the gem reserve
local function bestAffordable()
    for i = #crystals, 1, -1 do
        local c = crystals[i]
        if c.ptype == "Gems" and gems() - c.price >= state.gemReserve then return c.base end
    end
    return nil
end

-- how many hatches, on average, to land a named pet from this crystal
local function oddsFor(base, petName)
    local c = crystalByName(base)
    if not c then return nil end
    for _, p in ipairs(c.pool) do
        if p.name == petName then
            local chance = math.max(p.chance, 1)
            return { chance = p.chance, rarity = p.rarity,
                     expected = math.ceil(100 / chance),
                     cost = math.ceil(100 / chance) * c.price }
        end
    end
    return nil
end

local function hatchOnce()
    local base = state.hatchChoice
    if base == "Auto" then
        base = bestAffordable()
        if not base then return false, "nothing affordable above the reserve" end
    end
    local c = crystalFor(base)
    if not c then return false, "no such crystal: " .. tostring(base) end
    if c.ptype == "Gems" and gems() - c.price < state.gemReserve then
        return false, "gem reserve (" .. shorten(state.gemReserve) .. ")"
    end
    if state.autoSell then ensureRoom(state.keepFree) end
    if cap() - itemCount("pets") < 1 or cap() - itemCount("trails") < 1 then
        return false, "capacity full, nothing left to sell"
    end

    local beforePets, beforeTrails = {}, {}
    for _, r in ipairs(itemRows("pets")) do beforePets[r.it] = true end
    for _, r in ipairs(itemRows("trails")) do beforeTrails[r.it] = true end

    local g0 = gems()
    local name, rarity = openCrystalRemote:InvokeServer("openCrystal", c.base .. " Crystal")
    hatchStats.attempts = hatchStats.attempts + 1
    hatchStats.gems = hatchStats.gems + (g0 - gems())
    state.hatches = state.hatches + 1
    if name == nil then
        hatchStats.failed = hatchStats.failed + 1
        return false, "server refused (capacity or price)"
    end

    state.rarity[rarity or "?"] = (state.rarity[rarity or "?"] or 0) + 1

    -- what did we actually get?
    local got
    for _, r in ipairs(itemRows("pets")) do
        if not beforePets[r.it] then got = r break end
    end
    if got then
        hatchStats.pets = hatchStats.pets + 1
        got.kind = "pets"
    else
        for _, r in ipairs(itemRows("trails")) do
            if not beforeTrails[r.it] then got = r break end
        end
        if got then hatchStats.trails = hatchStats.trails + 1 end
    end

    -- sniper bookkeeping
    if state.snipePet ~= "" and name == state.snipePet then
        state.hits[state.snipePet] = (state.hits[state.snipePet] or 0) + 1
        notify((state.snipePet .. " hatched! (" .. shorten(c.price) .. " gems each)"), "good")
    end

    -- keep the good ones, sell the rest (policy lives in buildKeepSet)
    if state.autoSell and got then sellIfJunk(got) end
    return true, nil, name, rarity, c.base
end

local function hatchLoop()
    local carry, shownRate = 0, math.max(state.hatchRate, 1)
    local lastReport, statusAcc = 0, 0
    local started = os.clock()
    while state.hatcher and isCurrent() do
        local dt = RunService.Heartbeat:Wait()
        shownRate = math.max(state.hatchRate, 1)
        carry = math.min(carry + shownRate * dt, 12)
        local budget = math.floor(carry)
        carry = carry - budget
        local stopReason = nil
        for _ = 1, budget do
            if not state.hatcher then break end
            if state.hatchMax > 0 and hatchStats.attempts >= state.hatchMax then
                stopReason = "reached " .. state.hatchMax .. " hatches"
                state.hatcher = false
                break
            end
            local ok, err, name = hatchOnce()
            if not ok then
                if err and err:find("capacity") then
                    -- genuinely full and nothing sellable: wait for a sell
                    task.wait(0.5)
                elseif err == "server refused (capacity or price)" then
                    stopReason = "server refused (capacity or price)"
                    state.hatcher = false
                    break
                elseif err and (err:find("reserve") or err:find("affordable")) then
                    stopReason = err
                    state.hatcher = false
                    break
                end
            else
                statusAcc = statusAcc + 1
                if statusAcc >= 5 or os.clock() - lastReport > 1 then
                    statusAcc, lastReport = 0, os.clock()
                    local rar = {}
                    for k, v in ipairs({ "Omega", "Unique", "Epic", "Rare", "Advanced", "Basic" }) do
                        if state.rarity[v] then table.insert(rar, v .. " " .. state.rarity[v]) end
                    end
                    setStatus(string.format("%s: %d hatches  %.1f/s  %s", state.hatchChoice,
                        state.hatches, shownRate, table.concat(rar, " / ")), MONO.success)
                end
            end
        end
        if stopReason then
            setStatus("Hatch stopped: " .. stopReason, MONO.warn)
        end
    end
    state.hatcher = false
    state.hatchThread = nil
    local dt = math.max(os.clock() - started, 1)
    setStatus(string.format("Hatch farm stopped: %d hatches in %.0fs (%.2f/s), %s gems",
        hatchStats.attempts, dt, hatchStats.attempts / dt, shorten(hatchStats.gems)), MONO.warn)
end

local function toggleHatch(choice)
    choice = choice or state.hatchChoice or "Auto"
    if choice ~= "Auto" then
        local c = crystalByName(choice)
        if c then choice = c.base end
    end
    if state.hatcher and state.hatchChoice == choice then
        state.hatcher = false
        setStatus("Hatcher stopping...", MONO.sub)
        return
    end
    state.hatchChoice = choice
    state.hatcher = true
    if not state.hatchThread then
        state.hatchThread = spawnLoop(hatchLoop)
    end
end

-- ============ ORB FARM (unchanged from v4, verified) ============
-- oScript fires exactly this on touch: orbEvent:FireServer("collectOrb", orbName, currentMap.Value)
local function currentMap()
    local name = player:FindFirstChild("currentMap")
    name = name and name.Value or "City"
    local f = workspace:FindFirstChild("orbFolder")
    if f and f:FindFirstChild(name) then return name end
    if f then
        for _, c in ipairs(f:GetChildren()) do
            if #c:GetChildren() > 0 then return c.Name end
        end
    end
    return name
end

local ORB_BURST_CAP = 400
local function orbLoop()
    local fired = 0
    local map = currentMap()
    local names = {}
    for name in pairs(orbs) do table.insert(names, name) end
    if #names == 0 then names = { "Red Orb" } end
    table.sort(names)
    local function orbName()
        if state.orbChoice == "ALL" then return names[(fired % #names) + 1] end
        return state.orbChoice or names[1]
    end
    local carry, sinceStatus = 0, 0
    while state.orbFarming and isCurrent() do
        local dt = RunService.Heartbeat:Wait()
        carry = math.min(carry + math.max(state.orbRate, 1) * dt, ORB_BURST_CAP)
        local budget = math.floor(carry)
        carry = carry - budget
        if budget >= 1 then
            for _ = 1, budget do
                if orbEvent then orbEvent:FireServer("collectOrb", orbName(), map) end
                fired = fired + 1
                if not state.orbInfinite and fired >= state.orbAmount then break end
            end
            sinceStatus = sinceStatus + dt
            if sinceStatus > 0.25 then
                sinceStatus = 0
                setStatus(string.format("%s x%d  (%.0f/s)  %s", orbName(), fired, state.orbRate,
                    state.orbInfinite and "infinite" or (fired .. "/" .. state.orbAmount)), MONO.success)
            end
        end
        if not state.orbInfinite and fired >= state.orbAmount then break end
    end
    state.orbFarming = false
    state.orbThread = nil
    setStatus("Orb farm stopped after " .. fired .. " calls", MONO.warn)
end

local function toggleOrb(choice)
    if state.orbFarming and state.orbChoice == choice then
        state.orbFarming = false
        setStatus("Orb farm stopping...", MONO.sub)
        return
    end
    state.orbFarming = true
    state.orbChoice = choice
    if not state.orbThread then state.orbThread = spawnLoop(orbLoop) end
end

-- ============ HOOP LOOP (unchanged from v4, verified) ============
-- hoopEvent is server -> client only; the payout happens when the server sees the
-- character touch a hoop, so the lever is relocating Workspace.Hoops onto us.
local function hrp()
    local ch = player.Character
    return ch and ch:FindFirstChild("HumanoidRootPart")
end

local function hoopLoop()
    local home = {}
    local passes = 0
    while state.hoopLoop and isCurrent() do
        local root = hrp()
        if not root then task.wait(0.5) continue end
        local folder = workspace:FindFirstChild("Hoops")
        local list = {}
        if folder then
            for _, h in ipairs(folder:GetChildren()) do
                if h:IsA("BasePart") then
                    if not home[h] then home[h] = h.CFrame end
                    table.insert(list, h)
                end
            end
        end
        if #list == 0 then task.wait(1) continue end
        local before = statValue("Hoops")
        local anchor = root.Position
        for _, h in ipairs(list) do h.CFrame = CFrame.new(anchor + Vector3.new(0, 2, 0)) end
        task.wait(1.0)
        for _, h in ipairs(list) do
            local o = home[h]
            if o then h.CFrame = o end
        end
        passes = passes + 1
        setStatus(string.format("Hoops +%d this pass  pass %d  (8s debounce each)",
            statValue("Hoops") - before, passes), MONO.success)
        task.wait(7.6)
    end
    for h, o in pairs(home) do pcall(function() h.CFrame = o end) end
    state.hoopLoop = false
    state.hoopThread = nil
    setStatus("Hoop loop stopped", MONO.warn)
end

-- ============ QUEST AUTO-COLLECT (unchanged from v4, verified) ============
-- questsScript fires questsEvent:FireServer("collectQuest", questInstance)
local function collectQuests()
    local collected = 0
    local qroot = player:FindFirstChild("Quests")
    if not qroot then return 0 end
    for _, folderName in ipairs({ "Daily Quests", "Weekly Quests", "Story Quests", "completedQuests" }) do
        local folder = qroot:FindFirstChild(folderName)
        if folder then
            for _, q in ipairs(folder:GetChildren()) do
                if q:IsA("Folder") then
                    local reqs = q:FindFirstChild("requirements")
                    if reqs and #reqs:GetChildren() > 0 then
                        local done = true
                        for _, r in ipairs(reqs:GetChildren()) do
                            if r:IsA("ValueBase") then
                                local p = r:FindFirstChild("progress")
                                if p and p.Value < r.Value then done = false end
                            end
                        end
                        if done and questsEvent then
                            questsEvent:FireServer("collectQuest", q)
                            collected = collected + 1
                            state.session.quests = (state.session.quests or 0) + 1
                            task.wait(0.6)
                        end
                    end
                end
            end
        end
    end
    return collected
end

local function questLoop()
    while state.quest and isCurrent() do
        local n = collectQuests()
        setStatus(n > 0 and ("Collected " .. n .. " quest(s)") or "Quest watch: nothing complete yet",
            n > 0 and MONO.success or MONO.sub)
        task.wait(5)
    end
    state.quest = false
    state.questThread = nil
end

-- ============ SPEED RAMPS (unverified reward, kept for ramp quests) ============
local function rampLoop()
    local ramps = workspace:FindFirstChild("speedRamps")
    local index, tries = 1, 0
    while state.ramps and isCurrent() do
        if not ramps or #ramps:GetChildren() == 0 then
            setStatus("No speedRamps found", MONO.danger)
            task.wait(3)
        else
            local list = ramps:GetChildren()
            local model = list[((index - 1) % #list) + 1]
            index = index + 1
            local touch
            for _, d in ipairs(model:GetDescendants()) do
                if d:IsA("BasePart") and d.Name == "touchPart" then touch = d break end
            end
            local root = hrp()
            if touch and root then
                root.CFrame = CFrame.new(touch.Position + Vector3.new(0, 3, 0))
                if type(firetouchinterest) == "function" then
                    pcall(function() firetouchinterest(root, touch, 0) end)
                    task.wait(0.05)
                    pcall(function() firetouchinterest(root, touch, 1) end)
                end
                if speedRampEvent then speedRampEvent:FireServer("usedRamp") end
                tries = tries + 1
                setStatus("Ramp touches: " .. tries .. "  (unverified)", MONO.warn)
            end
        end
        task.wait(0.5)
    end
    state.ramps = false
    state.rampThread = nil
end

-- ============ AUTO REBIRTH (new) ============
-- confirmButton in gameGuiScript: rebirthEvent:FireServer("rebirthRequest") once
-- level >= calculateRequiredRebirthLevel(Rebirths.Value, player).
local function requiredRebirthLevel()
    if not GF then return nil end
    local ok, res = pcall(function() return GF.calculateRequiredRebirthLevel(rebirths(), player) end)
    return ok and res or nil
end

local function rebirthLoop()
    while state.autoRebirth and isCurrent() do
        local req = requiredRebirthLevel()
        if req and level() >= req then
            if rebirthEvent then rebirthEvent:FireServer("rebirthRequest") end
            state.rebirthsDone = state.rebirthsDone + 1
            setStatus(string.format("REBIRTH sent (%d this session, was level %d >= %d)",
                state.rebirthsDone, level(), req), MONO.rare)
            task.wait(6)
        else
            setStatus(string.format("Auto rebirth: level %d / %d needed (rebirths %d)",
                level(), req or -1, rebirths()), MONO.sub)
            task.wait(5)
        end
    end
    state.autoRebirth = false
    state.rebirthThread = nil
end

-- ============ ULTIMATE UPGRADES (new) ============
-- Los.secondGameGuiScript: ultimatesRemote:InvokeServer("upgradeUltimate", name)
-- costs Rebirths (globalFunctions.calculateUltimateRebirthCost), max 3-10 each.
local ULTIMATE_PRIORITY = {
    "Step Booster", "Gem Booster", "Ethereal Orbs", "Demon Hoops", "Infernal Gems",
    "+1 Pet Slot", "+10 Item Capacity", "x2 Chest Rewards", "x2 Quest Rewards",
    "x2 Trail Boosts", "Divine Rebirth", "+1 Daily Spin",
}

local function ultimateProgress(name)
    local owned = player:FindFirstChild("ultimatesFolder") and player.ultimatesFolder:FindFirstChild(name)
    local def = RS:FindFirstChild("gameUltimatesFolder") and RS.gameUltimatesFolder:FindFirstChild(name)
    local have = owned and owned.Value or 0
    local maxUp = def and def:FindFirstChild("maxUpgrades")
    local cost = nil
    if GF and def then
        local ok, res = pcall(function() return GF.calculateUltimateRebirthCost(def, have) end)
        if ok then cost = res end
    end
    return have, maxUp and maxUp.Value or 0, cost
end

local function ultimateLoop()
    while state.autoUltimates and isCurrent() do
        local bought = 0
        for _, name in ipairs(ULTIMATE_PRIORITY) do
            if not state.autoUltimates then break end
            local have, maxUp, cost = ultimateProgress(name)
            if cost and have < maxUp and rebirths() >= cost + state.rebirthReserve then
                local ok, res = pcall(function() return ultimatesRemote:InvokeServer("upgradeUltimate", name) end)
                if ok and res == true then
                    state.ultimatesBought = state.ultimatesBought + 1
                    bought = bought + 1
                    setStatus(string.format("Ultimate bought: %s (%d/%d) for %d rebirths",
                        name, have + 1, maxUp, cost), MONO.rare)
                    task.wait(1.2)
                else
                    setStatus("Ultimate refused: " .. name, MONO.warn)
                    task.wait(1.2)
                end
            end
        end
        if bought == 0 then
            setStatus(string.format("Ultimates: nothing affordable (rebirths %d, reserve %d)",
                rebirths(), state.rebirthReserve), MONO.sub)
        end
        task.wait(4)
    end
    state.autoUltimates = false
    state.ultimateThread = nil
end

-- ============ CHEST COLLECTOR (new) ============
-- gameGuiScript: checkChestRemote:InvokeServer("Golden Chest") etc., 6h cooldown,
-- returns (ok, currency, amount). Touching circleInner is what the game wires up,
-- so we stand on it and also invoke directly.
local CHESTS = {
    { label = "Golden Chest", model = "goldenChest" },
    { label = "Enchanted Chest", model = "enchantedChest" },
    { label = "Magma Chest", model = "magmaChest" },
    { label = "Jungle Chest", model = "jungleChest" },
}

local function chestLoop()
    while state.chests and isCurrent() do
        local got = 0
        for _, chest in ipairs(CHESTS) do
            if not state.chests then break end
            local model = workspace:FindFirstChild(chest.model)
            local inner = model and model:FindFirstChild("circleInner", true)
            if inner and hrp() then
                hrp().CFrame = CFrame.new(inner.Position + Vector3.new(0, 3, 0))
                task.wait(0.4)
            end
            local ok, res, currency, amount = pcall(function() return checkChestRemote:InvokeServer(chest.label) end)
            if ok and res == true then
                state.chestsOpened = state.chestsOpened + 1
                got = got + 1
                setStatus(string.format("%s: +%s %s", chest.label, shorten(amount or 0), tostring(currency)), MONO.rare)
            end
            task.wait(0.5)
        end
        if got == 0 then
            setStatus("Chests: nothing ready (6h cooldown)", MONO.sub)
        end
        task.wait(20)
    end
    state.chests = false
    state.chestThread = nil
end

-- =====================================================================
--   UI LAYER - Project UAI UI LIB window
--   (v5 farm core above is unchanged; this replaces the hand-built HUD)
-- =====================================================================
local UAI_URL = "https://raw.githubusercontent.com/Project-Ptolemy/ProjectUAI/main/dist/uai-ui.lua"
local libOk, UI = pcall(function()
    return loadstring(game:HttpGet(UAI_URL))()
end)
if not libOk or type(UI) ~= "table" or type(UI.CreateWindow) ~= "function" then
    error("[LOS Autofarm v5] could not load the Project UAI UI LIB from " .. UAI_URL)
end

local window = UI:CreateWindow({
    Id = "los-autofarm-v5",
    Title = "LOS Autofarm v5",
    Subtitle = "Legends of Speed - direct remote farm",
    GameName = "Legends of Speed",
    Width = 840,
    Height = 640,
})

-- every control handle the refresh loop needs
local U = {}

local function toneOf(colour)
    if colour == nil then return "Info" end
    if colour == MONO.success then return "Success" end
    if colour == MONO.warn then return "Warning" end
    if colour == MONO.danger then return "Danger" end
    return "Info"
end

setStatus = function(text, colour)
    text = tostring(text or "")
    U.tone = toneOf(colour)
    if U.status then U.status:SetText(text) end
    if U.badge then U.badge:Set(U.tone) end
end

notify = function(text, tone)
    local kind = "Info"
    if tone == "good" then kind = "Success"
    elseif tone == "bad" then kind = "Danger"
    elseif tone == "warn" then kind = "Warning" end
    window:Notify({
        Title = "Autofarm",
        Content = tostring(text or ""),
        Kind = kind,
        Duration = 4,
    })
end

-- ============ SESSION STATS ============
local function itemTotal(kind)
    local n = 0
    for _, r in ipairs(itemRows(kind)) do n = n + 1 end
    return n
end

state.session = {
    t = os.clock(),
    steps = statValue("Steps"), gems = gems(), exp = statValue("exp"),
    hoops = statValue("Hoops"), level = level(), rebirths = rebirths(),
    pets = itemTotal("pets"), trails = itemTotal("trails"), hatches = 0,
}

local function sessionText()
    local s = state.session
    local dt = math.max(os.clock() - s.t, 1)
    local m = dt / 60
    local best = ""
    for name, count in pairs(state.hits) do
        best = name .. " x" .. count
        break
    end
    return string.format(
        "Steps %s (%s/m)   Gems %s (%s/m)\nLevel %d   Rebirths %d   Pets %d/%d   Trails %d/%d\nHatches %d (%.1f/m)   Gems spent %s   Sold %d pets / %d trails%s",
        shorten(statValue("Steps")), shorten((statValue("Steps") - s.steps) / m),
        shorten(gems()), shorten((gems() - s.gems) / m),
        level(), rebirths(), itemTotal("pets"), cap(), itemTotal("trails"), cap(),
        hatchStats.attempts, hatchStats.attempts / m, shorten(hatchStats.gems),
        sellStats.pets, sellStats.trails, best ~= "" and ("\nSniper: " .. best) or "")
end

-- ============ LOOP STARTERS (single place, shared by UI and API sync) ============
local function hatchSet(on, choice)
    choice = choice or state.hatchChoice or "Auto"
    if on then
        if state.hatcher and state.hatchChoice ~= choice then
            toggleHatch(choice)
        elseif not state.hatcher then
            toggleHatch(choice)
        end
    elseif state.hatcher then
        toggleHatch(state.hatchChoice)
    end
    state.hatchChoice = choice
end

local function orbSet(on, choice)
    choice = choice or state.orbChoice or "ALL"
    if on then
        if not (state.orbFarming and state.orbChoice == choice) then
            toggleOrb(choice)
        end
    elseif state.orbFarming then
        toggleOrb(state.orbChoice)
    end
end

local function evolveApply()
    if state.autoEvolve then
        state.evolvePets, state.evolveTrails = true, true
    end
    if (state.autoEvolve or state.evolvePets or state.evolveTrails) and not state.evolveThread then
        state.evolveThread = spawnLoop(evolveLoop)
    end
end

local function equipApply()
    if state.autoEquip and not state.equipThread then
        state.equipThread = spawnLoop(equipLoop)
    end
end

-- flag name, its thread handle name, and the loop body
local SIMPLE_LOOPS = {
    { "hoop", "hoopLoop", "hoopThread", hoopLoop },
    { "quest", "quest", "questThread", questLoop },
    { "ramps", "ramps", "rampThread", rampLoop },
    { "rebirth", "autoRebirth", "rebirthThread", rebirthLoop },
    { "ultimates", "autoUltimates", "ultimateThread", ultimateLoop },
    { "chests", "chests", "chestThread", chestLoop },
}

local function simpleApply(flagKey, threadKey, fn, on)
    state[flagKey] = on and true or false
    if state[flagKey] and not state[threadKey] then
        state[threadKey] = spawnLoop(fn)
    end
end

-- push state back into the controls (the ;los plugin and the API can flip
-- flags without going through the UI, so the window follows state)
local function syncControls()
    if not U.hatchRun then return end
    local ok, err = pcall(function()
    U.hatchRun:Set(state.hatcher and true or false, true)
    U.crystal:Set(state.hatchChoice, true)
    U.orbRun:Set(state.orbFarming and true or false, true)
    U.orbChoice:Set(state.orbChoice or "ALL", true)
    U.orbAmount:SetDisabled(state.orbInfinite)
    U.hoop:Set(state.hoopLoop and true or false, true)
    U.quest:Set(state.quest and true or false, true)
    U.equipSet:Set(state.autoEquip and true or false, true)
    U.evolveAll:Set(state.autoEvolve and true or false, true)
    U.evolvePets:Set(state.evolvePets and not state.autoEvolve and true or false, true)
    U.evolveTrails:Set(state.evolveTrails and not state.autoEvolve and true or false, true)
    for _, L in ipairs(SIMPLE_LOOPS) do
        if U[L[1]] then U[L[1]]:Set(state[L[2]] and true or false, true) end
    end
    -- keep-list field: mirror the live list unless the user is typing in it
    if U.keepName then
        local names = {}
        for name in pairs(state.keepNames) do table.insert(names, name) end
        table.sort(names)
        local mirror = table.concat(names, ", ")
        local shown = tostring(U.keepName:Get() or "")
        if shown == "" or shown == (U.keepMirror or "") then
            U.keepMirror = mirror
            U.keepName:Set(mirror, true)
        end
    end
end)
    if not ok and not U.syncWarned then
        U.syncWarned = true
        warn("[LOS Autofarm v5] control sync error: " .. tostring(err))
    end
end

-- read every control back into state and reconcile the workers
local function applyControls()
    if not U.hatchRun then return end
    state.autoSell = U.autoSell:Get()
    state.rankMode = U.rankMode:Get()
    state.keepTop = U.keepTop:Get()
    state.keepDupes = U.keepDupes:Get()
    state.keepFree = U.keepFree:Get()
    state.keepEvolved = U.keepEvolved:Get()
    state.evolveAll = not U.evolveOnlyKept:Get()
    local floor = U.floor:Get()
    state.sellBelow = (floor == "OFF" or floor == nil) and "Basic" or floor
    state.hatchRate = U.hatchRate:Get()
    state.gemReserve = U.gemReserve:Get()
    state.hatchMax = U.hatchMax:Get()
    state.orbRate = U.orbRate:Get()
    state.orbAmount = U.orbAmount:Get()
    state.orbInfinite = U.orbInfinite:Get()
    state.rebirthReserve = U.rebirthReserve:Get()

    hatchSet(U.hatchRun:Get() and true or false, U.crystal:Get())
    orbSet(U.orbRun:Get() and true or false, U.orbChoice:Get())
    U.orbAmount:SetDisabled(state.orbInfinite)

    for _, L in ipairs(SIMPLE_LOOPS) do
        if U[L[1]] then simpleApply(L[2], L[3], L[4], U[L[1]]:Get()) end
    end

    state.autoEvolve = U.evolveAll:Get()
    state.evolvePets = U.evolvePets:Get()
    state.evolveTrails = U.evolveTrails:Get()
    if not state.autoEvolve then
        if not U.evolvePets:Get() then state.evolvePets = false end
        if not U.evolveTrails:Get() then state.evolveTrails = false end
    end
    evolveApply()
    state.autoEquip = U.equipSet:Get()
    equipApply()
    syncControls()
end

-- ============ TAB: HATCH ============
local hatchTab = window:Tab({ Id = "hatch", Title = "Hatch" })

local crystalOptions = { { Label = "Auto (most expensive affordable)", Value = "Auto" } }
for _, c in ipairs(crystals) do
    table.insert(crystalOptions, {
        Label = string.format("%s - %s %s", c.base, shorten(c.price), c.ptype),
        Value = c.base,
    })
end

local hSec = hatchTab:Section({
    Title = "Hatch farm",
    Description = 'openCrystalRemote:InvokeServer("openCrystal", "<Name> Crystal") - no menu, no walk, no cooldown. Refused only at pet/trail capacity.',
})

U.crystal = hSec:Dropdown({
    Id = "hatch-crystal",
    Text = "Crystal",
    Options = crystalOptions,
    Default = state.hatchChoice,
    Searchable = true,
    Placeholder = "Pick a crystal",
    Callback = function(value)
        state.hatchChoice = value
        if state.hatcher then hatchSet(true, value) end
    end,
})

U.hatchRun = hSec:Toggle({
    Id = "hatch-running",
    Text = "Hatch loop",
    Default = state.hatcher,
    Description = "Start or stop hatching the selected crystal.",
    Callback = function(on) hatchSet(on and true or false) end,
})

U.hatchRate = hSec:Slider({
    Id = "hatch-rate",
    Text = "Hatches per second",
    Min = 1, Max = 20, Step = 1,
    Default = state.hatchRate,
    Callback = function(v) state.hatchRate = v end,
})

U.gemReserve = hSec:Slider({
    Id = "gem-reserve",
    Text = "Gem reserve",
    Min = 0, Max = 10000000, Step = 100000,
    Default = state.gemReserve,
    Suffix = " gems",
    Description = "Stops spending once gems drop under this.",
    Callback = function(v) state.gemReserve = v end,
})

U.hatchMax = hSec:Slider({
    Id = "hatch-max",
    Text = "Stop after N hatches (0 = forever)",
    Min = 0, Max = 5000, Step = 50,
    Default = state.hatchMax,
    Callback = function(v) state.hatchMax = v end,
})

U.snipe = hSec:Input({
    Id = "snipe-pet",
    Text = "Snipe pet name",
    Default = state.snipePet or "",
    Placeholder = "e.g. Swift Samurai",
    Description = "Logs the live odds and expected cost for this pet.",
})

local function snipeCommit(value)
    value = tostring(value or "")
    state.snipePet = value
    if value == "" then
        notify("sniper cleared", "info")
        return
    end
    local base = state.hatchChoice ~= "Auto" and state.hatchChoice or "Jungle"
    local odds = oddsFor(base, value)
    if odds then
        notify(string.format("%s: %s %d%%, ~%d hatches (~%s gems)",
            value, odds.rarity, odds.chance, odds.expected, shorten(odds.cost)), "good")
    else
        notify("no crystal in the live tables drops " .. value, "warn")
    end
end
if type(U.snipe.OnCommit) == "function" then
    U.snipe:OnCommit(snipeCommit)
else
    U.snipe:OnChanged(snipeCommit)
end

local dropSec = hatchTab:Section({
    Title = "Live drop tables",
    Description = "Read from ReplicatedStorage.crystalChances at load.",
    Collapsible = true,
    Collapsed = true,
})
do
    local lines = {}
    for _, c in ipairs(crystals) do
        if #c.pool > 0 then table.insert(lines, c.base .. ": " .. crystalLabel(c)) end
    end
    dropSec:Label({ Text = table.concat(lines, "\n") })
end

-- ============ TAB: ORBS ============
local orbTab = window:Tab({ Id = "orbs", Title = "Orbs" })
local oSec = orbTab:Section({
    Title = "Orb farm",
    Description = 'orbEvent:FireServer("collectOrb", name, map) - pays per call, no rate limit and no cooldown.',
})

local orbOptions = { { Label = "ALL (rotate every payout type)", Value = "ALL" } }
do
    local names = {}
    for name in pairs(orbs) do table.insert(names, name) end
    table.sort(names)
    for _, name in ipairs(names) do
        table.insert(orbOptions, {
            Label = string.format("%s -> %s", name, orbs[name].stat),
            Value = name,
        })
    end
end

U.orbChoice = oSec:Dropdown({
    Id = "orb-choice",
    Text = "Orb",
    Options = orbOptions,
    Default = state.orbChoice or "ALL",
    Searchable = true,
    Callback = function(value)
        state.orbChoice = value
        if state.orbFarming then orbSet(true, value) end
    end,
})

U.orbRun = oSec:Toggle({
    Id = "orb-running",
    Text = "Orb loop",
    Default = state.orbFarming,
    Callback = function(on) orbSet(on and true or false) end,
})

U.orbRate = oSec:Slider({
    Id = "orb-rate",
    Text = "Calls per second",
    Min = 5, Max = 5000, Step = 25,
    Default = state.orbRate,
    Callback = function(v) state.orbRate = v end,
})

U.orbAmount = oSec:Slider({
    Id = "orb-amount",
    Text = "Calls per payout",
    Min = 100, Max = 1000000, Step = 5000,
    Default = state.orbAmount,
    Disabled = state.orbInfinite,
    Callback = function(v) state.orbAmount = v end,
})

U.orbInfinite = oSec:Toggle({
    Id = "orb-infinite",
    Text = "Infinite calls per payout",
    Default = state.orbInfinite,
    Description = "Off uses the Calls per payout value above.",
    Callback = function(on)
        state.orbInfinite = on
        U.orbAmount:SetDisabled(on)
    end,
})

oSec:Label({
    Text = "measured per call: Red 3397 steps, Ethereal 5557 steps, Gem 2836 gems, Yellow 12 exp",
})

-- ============ TAB: PETS ============
local petTab = window:Tab({ Id = "pets", Title = "Pets" })

local sSec = petTab:Section({
    Title = "Sell policy",
    Description = "What gets auto-sold once an item is ranked. Equipped and evolved keepers are never sold.",
})

U.autoSell = sSec:Toggle({
    Id = "sell-auto",
    Text = "Auto-sell junk (keeps capacity free)",
    Default = state.autoSell,
    Callback = function(v) state.autoSell = v end,
})

U.rankMode = sSec:Segmented({
    Id = "sell-rank",
    Text = "Rank by",
    Options = { "MARKET", "VALUE", "RARITY" },
    Default = state.rankMode,
    Callback = function(v)
        state.rankMode = v
        notify("ranking by " .. v, "info")
    end,
})

U.keepTop = sSec:Slider({
    Id = "sell-keeptop",
    Text = "Keep best N pets/trails",
    Min = 0, Max = 20, Step = 1,
    Default = state.keepTop,
    Callback = function(v) state.keepTop = v end,
})

U.keepDupes = sSec:Slider({
    Id = "sell-keepdupes",
    Text = "Keep N copies of a kept name (evolve fodder)",
    Min = 0, Max = 10, Step = 1,
    Default = state.keepDupes,
    Callback = function(v) state.keepDupes = v end,
})

U.keepFree = sSec:Slider({
    Id = "sell-keepfree",
    Text = "Free slots to hold open",
    Min = 1, Max = 10, Step = 1,
    Default = state.keepFree,
    Callback = function(v) state.keepFree = v end,
})

U.keepEvolved = sSec:Toggle({
    Id = "sell-keepevolved",
    Text = "Keep evolved pets/trails",
    Default = state.keepEvolved,
    Callback = function(v) state.keepEvolved = v end,
})

U.evolveOnlyKept = sSec:Toggle({
    Id = "sell-evolvekept",
    Text = "Evolve only kept names",
    Default = not state.evolveAll,
    Callback = function(v)
        state.evolveAll = not v
        evolveApply()
    end,
})

U.floor = sSec:Segmented({
    Id = "sell-floor",
    Text = "Rarity floor (keep this tier and above, dump below)",
    Options = {
        { Label = "OFF", Value = "OFF" },
        { Label = "Basic", Value = "Basic" },
        { Label = "Advanced", Value = "Advanced" },
        { Label = "Rare", Value = "Rare" },
        { Label = "Epic", Value = "Epic" },
        { Label = "Unique", Value = "Unique" },
    },
    Default = (RARITY_ORDER[state.sellBelow] or 1) <= 1 and "OFF" or state.sellBelow,
    Callback = function(v)
        state.sellBelow = v == "OFF" and "Basic" or v
        notify(v == "OFF" and "rarity floor off" or ("keeping " .. v .. " and above, dumping below"), "info")
    end,
})

U.keepName = sSec:Input({
    Id = "sell-keepname",
    Text = "Keep list",
    Placeholder = "Add a name (empty clears the list)",
    Description = "Names here are never sold, and are used as evolve fodder.",
})

sSec:Button({
    Id = "sell-keepadd",
    Text = "Add to keep list",
    Callback = function()
        local text = tostring(U.keepName:Get() or "")
        text = text:gsub("^%s+", ""):gsub("%s+$", "")
        if text == "" then
            state.keepNames = {}
            notify("keep list cleared", "info")
        else
            state.keepNames[text] = true
            notify("kept: " .. text, "good")
        end
        U.keepName:Set("", true)
    end,
})

sSec:Button({
    Id = "sell-junk",
    Text = "Sell junk now",
    Description = "Frees up to 30 slots per kind using the policy above.",
    Callback = function()
        local freed = 0
        for _ = 1, 30 do
            local f = ensureRoom(state.keepFree)
            freed = freed + f
            if f == 0 then break end
        end
        notify(freed > 0 and ("freed " .. freed .. " slots") or "nothing sellable",
            freed > 0 and "good" or "warn")
    end,
})

sSec:Button({
    Id = "sell-preset-rare",
    Text = "Apply preset: rare-pet farm",
    Style = "Primary",
    Description = "Hatch Jungle, keep Swift Samurai, 5 duplicates, dump everything below Unique.",
    Callback = function()
        state.keepTop = 0
        state.keepDupes = 5
        state.keepEvolved = true
        state.sellBelow = "Unique"
        state.keepNames["Swift Samurai"] = true
        state.snipePet = "Swift Samurai"
        state.hatchChoice = "Jungle"
        state.rankMode = "MARKET"
        state.autoEvolve = true
        state.evolvePets, state.evolveTrails = true, true
        evolveApply()
        U.keepTop:Set(0, true)
        U.keepDupes:Set(5, true)
        U.keepEvolved:Set(true, true)
        U.floor:Set("Unique", true)
        U.rankMode:Set("MARKET", true)
        U.evolveOnlyKept:Set(false, true)
        U.snipe:Set("Swift Samurai", true)
        U.crystal:Set("Jungle", true)
        if state.hatcher then hatchSet(true, "Jungle") end
        notify("preset: keep+evolve Swift Samurai, dump everything below Unique", "good")
        setStatus("Preset applied: hatch Jungle, keep Swift Samurai, auto-evolve on", MONO.rare)
        syncControls()
    end,
})

local lSec = petTab:Section({ Title = "Live policy state" })
U.sellPreview = lSec:Label({ Id = "live-sellpreview", Text = "next to sell: ..." })
U.evolveWatch = lSec:Label({ Id = "live-evolvewatch", Text = "evolve watch: ..." })
U.keepList = lSec:Label({ Id = "live-keeplist", Text = "keep list: none" })

-- ============ AUTO EVOLVE ============
local eSec = petTab:Section({
    Title = "Auto evolve",
    Description = "Five non-evolved copies of the same name become one evolved copy: the server consumes four and leaves one marked evolved.",
})

U.evolveAll = eSec:Toggle({
    Id = "evolve-all",
    Text = "Auto evolve everything",
    Default = state.autoEvolve,
    Callback = function(on)
        state.autoEvolve = on
        if on then
            state.evolvePets, state.evolveTrails = true, true
        else
            state.evolvePets, state.evolveTrails = false, false
        end
        evolveApply()
        syncControls()
    end,
})

U.evolvePets = eSec:Toggle({
    Id = "evolve-pets",
    Text = "Auto evolve pets only",
    Default = state.evolvePets and not state.autoEvolve,
    Callback = function(on)
        state.evolvePets = on
        evolveApply()
    end,
})

U.evolveTrails = eSec:Toggle({
    Id = "evolve-trails",
    Text = "Auto evolve trails only",
    Default = state.evolveTrails and not state.autoEvolve,
    Callback = function(on)
        state.evolveTrails = on
        evolveApply()
    end,
})

eSec:Button({
    Id = "evolve-now",
    Text = "Evolve everything ready now",
    Callback = function()
        local a, b = evolveNow()
        notify(string.format("evolved %d pet name(s), %d trail name(s)", #a, #b),
            (#a + #b) > 0 and "good" or "warn")
    end,
})

eSec:Paragraph({
    Title = "Evolve remotes",
    Content = 'petEvolveEvent:FireServer("evolvePet", name) and evolveTrailEvent:FireServer("evolveTrail", instance). Evolution needs five copies of the same name, so the sell policy keeps duplicates of kept names.',
})

-- ============ AUTO EQUIP ============
local qSec = petTab:Section({ Title = "Auto equip" })

U.equipSet = qSec:Toggle({
    Id = "equip-auto",
    Text = "Auto-equip best pets",
    Default = state.autoEquip,
    Description = "Fills every empty pet slot with the best pet available.",
    Callback = function(on)
        state.autoEquip = on
        equipApply()
    end,
})

qSec:Button({
    Id = "equip-now",
    Text = "Equip best now",
    Callback = function()
        local n = autoEquipStep()
        notify(n > 0 and ("equipped " .. n .. " pets") or "already optimal",
            n > 0 and "good" or "info")
    end,
})

qSec:Label({
    Text = "Equipping needs a free slot: v5 unequips the weakest equipped pet first, then equips. Equipped pets can never be sold by the sell engine.",
})

-- ============ TAB: WORLD LOOPS ============
local worldTab = window:Tab({ Id = "world", Title = "Loops" })
local TEXTURES = {
    hoop = { Title = "Hoop farm", Text = "Hoop loop (35 hoops per pass, 8.6s cycle)",
        Description = "Hoops pay out on a per-hoop debounce." },
    quest = { Title = "Quests", Text = "Collect completed quests",
        Description = "Collects anything the quest remote accepts as complete." },
    rebirth = { Title = "Rebirth", Text = "Auto rebirth when level is enough",
        Description = 'rebirthEvent:FireServer("rebirthRequest") once level >= calculateRequiredRebirthLevel(rebirths, player).' },
    ultimates = { Title = "Ultimate upgrades", Text = "Auto-buy ultimates",
        Description = 'ultimatesRemote:InvokeServer("upgradeUltimate", name). Costs rebirths, so it is ordered by payoff: Step Booster, Gem Booster, Ethereal Orbs, Demon Hoops, Infernal Gems, slots, chests, quests, trails, Divine Rebirth, spins.' },
    chests = { Title = "Chests", Text = "Open golden / enchanted / magma / jungle chests",
        Description = 'checkChestRemote:InvokeServer("Golden Chest") and friends. Each has its own 6 hour cooldown.' },
    ramps = { Title = "Speed ramps", Text = "Touch speed ramps",
        Description = "Unverified ramp loop, kept from v4." },
}

for _, L in ipairs(SIMPLE_LOOPS) do
    local meta = TEXTURES[L[1]]
    local sec = worldTab:Section({ Title = meta.Title, Description = meta.Description })
    U[L[1]] = sec:Toggle({
        Id = "loop-" .. L[1],
        Text = meta.Text,
        Default = state[L[2]],
        Callback = function(on)
            simpleApply(L[2], L[3], L[4], on and true or false)
        end,
    })
    if L[1] == "rebirth" then
        U.rebirthReserve = sec:Slider({
            Id = "rebirth-reserve",
            Text = "Keep N rebirths spare",
            Min = 0, Max = 20, Step = 1,
            Default = state.rebirthReserve,
            Description = "Stops spending rebirths when the live count is at or under this.",
            Callback = function(v) state.rebirthReserve = v end,
        })
    end
end

-- ============ TAB: STATUS ============
local statusTab = window:Tab({ Id = "status", Title = "Status" })

local stSec = statusTab:Section({ Title = "Live status" })
U.badge = stSec:Badge({ Id = "status-tone", Default = "Info", Kind = "Secondary" })
U.status = stSec:Label({ Id = "status-text", Text = "Idle" })

local ssSec = statusTab:Section({ Title = "Session" })
U.session = ssSec:Label({ Id = "session-text", Text = sessionText() })
U.petBar = ssSec:Progress({ Id = "cap-pets", Text = "Pet capacity", Min = 0, Max = 100, Default = 0 })
U.petCap = ssSec:Label({ Id = "cap-pets-text", Text = "pets 0/0" })
U.trailBar = ssSec:Progress({ Id = "cap-trails", Text = "Trail capacity", Min = 0, Max = 100, Default = 0 })
U.trailCap = ssSec:Label({ Id = "cap-trails-text", Text = "trails 0/0" })

local ctlSec = statusTab:Section({ Title = "Controls" })
ctlSec:Button({
    Id = "stop-all",
    Text = "Stop all loops",
    Style = "Danger",
    Description = "Stops every farm loop and leaves the window open.",
    Callback = function()
        state.orbFarming, state.hoopLoop, state.hatcher, state.quest = false, false, false, false
        state.evolvePets, state.evolveTrails, state.ramps = false, false, false
        state.autoRebirth, state.autoUltimates, state.chests, state.autoEquip = false, false, false, false
        state.autoEvolve = false
        syncControls()
        notify("all loops stopping", "warn")
    end,
})
ctlSec:Keybind({
    Id = "hotkey-hatch",
    Text = "Hatch hotkey",
    Default = Enum.KeyCode.H,
    Mode = "Toggle",
    Callback = function(active)
        hatchSet(active and true or false)
        syncControls()
    end,
})

-- ============ CONFIGURATION ============
local cfgSec = statusTab:Section({
    Title = "Configuration",
    Description = "Named profiles are stored under ProjectUAI/UI/ by the library. The profile called \"default\" is restored silently when the script starts; everything else is explicit.",
})

U.profile = cfgSec:Input({
    Id = "cfg-profile",
    Text = "Profile name",
    Default = "default",
    MaxLength = 48,
    Persist = false,
})

U.json = cfgSec:Input({
    Id = "cfg-json",
    Text = "Configuration JSON",
    MultiLine = true,
    Lines = 4,
    MaxLength = 262144,
    Placeholder = "Export fills this field; Import reads it back.",
    Persist = false,
})

cfgSec:Button({
    Id = "cfg-save",
    Text = "Save profile",
    ActionText = "Saving",
    Style = "Primary",
    Callback = function()
        local ok, info = window:SaveConfig(U.profile:Get())
        if ok then
            notify("saved " .. tostring(info), "good")
        else
            notify("save failed: " .. tostring(info), "bad")
        end
    end,
})

cfgSec:Button({
    Id = "cfg-load",
    Text = "Load profile",
    ActionText = "Loading",
    Description = "Loads the profile into the controls and applies it to the running loops.",
    Callback = function()
        local ok, info = window:LoadConfig(U.profile:Get(), { Silent = true })
        if ok == false then
            notify("load failed: " .. tostring(info), "bad")
            return
        end
        applyControls()
        notify("profile applied", "good")
    end,
})

cfgSec:Button({
    Id = "cfg-export",
    Text = "Export to field",
    Callback = function()
        U.json:Set(window:ExportConfig(), true)
        notify("configuration exported", "info")
    end,
})

cfgSec:Button({
    Id = "cfg-copy",
    Text = "Copy JSON to clipboard",
    Callback = function()
        local json = window:ExportConfig()
        U.json:Set(json, true)
        if type(setclipboard) == "function" then
            pcall(setclipboard, json)
            notify("configuration copied to clipboard", "good")
        else
            notify("clipboard unavailable; use the field below", "warn")
        end
    end,
})

cfgSec:Button({
    Id = "cfg-import",
    Text = "Import from field",
    Callback = function()
        local text = tostring(U.json:Get() or "")
        if text == "" then
            notify("nothing to import", "warn")
            return
        end
        local ok, info = window:ImportConfig(text, { Silent = true })
        if ok then
            applyControls()
            notify("imported " .. tostring(info) .. " value(s)", "good")
        else
            notify("import failed: " .. tostring(info), "bad")
        end
    end,
})

cfgSec:Button({
    Id = "cfg-reset",
    Text = "Reset controls to session defaults",
    Style = "Danger",
    Callback = function()
        window:Confirm({
            Title = "Reset controls?",
            Content = "Every control returns to the value it had when this window was created, then the running loops are re-applied.",
            ConfirmText = "Reset",
            Danger = true,
            Callback = function()
                for _, control in pairs(U) do
                    if type(control) == "table" and type(control.Get) == "function"
                        and type(control.Reset) == "function" then
                        pcall(function() control:Reset(true) end)
                    end
                end
                applyControls()
                notify("controls reset", "info")
            end,
        })
    end,
})

-- keep list: the field is a command box, and it mirrors the live list whenever
-- it is empty or already showing the mirror, so a saved profile keeps the names
local function keepNameCommit(value)
    for part in tostring(value or ""):gmatch("[^,]+") do
        local name = part:gsub("^%s+", ""):gsub("%s+$", "")
        if name ~= "" then state.keepNames[name] = true end
    end
end
if type(U.keepName.OnCommit) == "function" then
    U.keepName:OnCommit(keepNameCommit)
else
    U.keepName:OnChanged(keepNameCommit)
end

-- ============ WINDOW CLEANUP ============
local function stopEverything()
    state.orbFarming, state.hoopLoop, state.hatcher, state.quest = false, false, false, false
    state.evolvePets, state.evolveTrails, state.ramps = false, false, false
    state.autoRebirth, state.autoUltimates, state.chests, state.autoEquip = false, false, false, false
    state.autoEvolve = false
end

window:OnDestroy(function()
    stopEverything()
    for _, key in ipairs({ "orbThread", "hoopThread", "hatchThread", "questThread", "petThread",
        "trailThread", "evolveThread", "rampThread", "rebirthThread", "ultimateThread",
        "chestThread", "equipThread" }) do
        state[key] = nil
    end
end)

-- ============ RESTORE SAVED PROFILE ============
do
    local restored = false
    pcall(function()
        local r1, r2 = window:LoadConfig("default", { Silent = true })
        if r1 == false then
            notify("saved profile not restored: " .. tostring(r2), "warn")
        else
            restored = r1 ~= nil or r2 ~= nil
        end
    end)
    if restored then applyControls() end
end


-- ============ PROGRAMMATIC CONTROL (drives the ;los IY plugin) ============
GENV.__LOS_AUTOFARM = {
    instance = INSTANCE_ID,
    state = state,
    crystals = crystals,
    prices = priceOf,
    stats = { hatch = hatchStats, sell = sellStats, equip = equipStats },
    orbFarm = function(choice)
        if choice == nil then state.orbFarming = false return end
        toggleOrb(choice)
    end,
    hatch = function(choice)
        if choice == nil then state.hatcher = false return end
        toggleHatch(choice)
    end,
    hoop = function(on)
        if on and not state.hoopLoop then state.hoopLoop = true
            if not state.hoopThread then state.hoopThread = spawnLoop(hoopLoop) end
        elseif not on then state.hoopLoop = false end
    end,
    quests = function(on)
        if on and not state.quest then state.quest = true
            if not state.questThread then state.questThread = spawnLoop(questLoop) end
        elseif not on then state.quest = false end
    end,
    evolve = function(which, on)
        if which == "all" then
            state.autoEvolve = on ~= false
            if state.autoEvolve then
                state.evolvePets, state.evolveTrails = true, true
                if not state.evolveThread then state.evolveThread = spawnLoop(evolveLoop) end
            else
                state.evolvePets, state.evolveTrails = false, false
            end
        elseif which == "pets" then
            state.evolvePets = on ~= false
            if state.evolvePets and not state.evolveThread then state.evolveThread = spawnLoop(evolveLoop) end
        elseif which == "trails" then
            state.evolveTrails = on ~= false
            if state.evolveTrails and not state.evolveThread then state.evolveThread = spawnLoop(evolveLoop) end
        end
    end,
    evolveNow = evolveNow,
    evolveReady = function()
        local out = {}
        for _, kind in ipairs({ "pets", "trails" }) do
            for name, n in pairs(evolveCounts(kind)) do
                if n >= 2 then table.insert(out, string.format("%s %s %d/5", kind, name, n)) end
            end
        end
        table.sort(out)
        return out
    end,
    sellPreview = function(kind, n) return sellPreview(kind or "pets", n or 5) end,
    rank = function(mode)
        if mode then state.rankMode = mode end
        return state.rankMode
    end,
    sellFloor = function(r)
        if r then state.sellBelow = r end
        return state.sellBelow
    end,
    keepDupes = function(n)
        if n then state.keepDupes = n end
        return state.keepDupes
    end,
    ramps = function(on)
        if on and not state.ramps then state.ramps = true
            if not state.rampThread then state.rampThread = spawnLoop(rampLoop) end
        elseif not on then state.ramps = false end
    end,
    sellJunk = function(n)
        local freed = 0
        for _ = 1, n or 20 do
            local f = ensureRoom(state.keepFree)
            freed = freed + f
            if f == 0 then break end
        end
        return freed
    end,
    sellAll = function()
        local n = 0
        state.keepTop, state.keepDupes = 0, 0
        for _ = 1, 80 do
            if not sellLowest("pets") then break end
            n = n + 1
            task.wait(0.1)
        end
        for _ = 1, 80 do
            if not sellLowest("trails") then break end
            n = n + 1
            task.wait(0.1)
        end
        return n
    end,
    autoEquip = function(on)
        state.autoEquip = on and true or false
        if state.autoEquip and not state.equipThread then state.equipThread = spawnLoop(equipLoop) end
    end,
    equipNow = function() return autoEquipStep() end,
    rebirth = function(on)
        state.autoRebirth = on and true or false
        if state.autoRebirth and not state.rebirthThread then state.rebirthThread = spawnLoop(rebirthLoop) end
    end,
    ultimates = function(on)
        state.autoUltimates = on and true or false
        if state.autoUltimates and not state.ultimateThread then state.ultimateThread = spawnLoop(ultimateLoop) end
    end,
    chests = function(on)
        state.chests = on and true or false
        if state.chests and not state.chestThread then state.chestThread = spawnLoop(chestLoop) end
    end,
    snipe = function(petName, crystal)
        state.snipePet = petName or ""
        if crystal then
            local c = crystalByName(crystal)
            state.hatchChoice = c and c.base or crystal
        end
        if state.snipePet ~= "" then toggleHatch(state.hatchChoice) end
        local base = crystalByName(state.hatchChoice) and crystalByName(state.hatchChoice).base or "Jungle"
        return oddsFor(base, state.snipePet)
    end,
    keep = function(petName, on)
        if petName == nil then return state.keepNames end
        state.keepNames[petName] = on ~= false or nil
        return state.keepNames
    end,
    oneHatch = function(crystal)
        local saved = state.hatchChoice
        if crystal then state.hatchChoice = crystal end
        local ok, err, name, rarity = hatchOnce()
        state.hatchChoice = saved
        return ok, err, name, rarity
    end,
    collectQuests = collectQuests,
    stopAll = function()
        state.orbFarming, state.hoopLoop, state.hatcher, state.quest = false, false, false, false
        state.evolvePets, state.evolveTrails, state.ramps = false, false, false
        state.autoRebirth, state.autoUltimates, state.chests, state.autoEquip = false, false, false, false
        state.autoEvolve = false
    end,
}
-- ============ REFRESH LOOP (UI LIB EDITION) ============
do
    local acc, accPill = 0, 0
    local conn, warned = nil, false
    conn = RunService.Heartbeat:Connect(function(dt)
        if not isCurrent() then conn:Disconnect() return end
        local ok, err = pcall(function()
        accPill = accPill + dt
        if accPill >= 0.2 then
            accPill = 0
            syncControls()
        end
        acc = acc + dt
        if acc < 1 then return end
        acc = 0
        if U.session then U.session:SetText(sessionText()) end
        local pd = sellPreview("pets", 3)
        local td = sellPreview("trails", 2)
        if U.sellPreview then
            U.sellPreview:SetText("next pets to sell: "
                .. (#pd > 0 and table.concat(pd, ", ") or "nothing")
                .. "\nnext trails: "
                .. (#td > 0 and table.concat(td, ", ") or "nothing"))
        end
        local watch = {}
        for _, kind in ipairs({ "pets", "trails" }) do
            for name, n in pairs(evolveCounts(kind)) do
                if n >= 2 then table.insert(watch, name .. " " .. n .. "/5") end
            end
        end
        table.sort(watch)
        if U.evolveWatch then
            U.evolveWatch:SetText("evolve watch (non-evolved copies): "
                .. (#watch > 0 and table.concat(watch, ", ") or "nothing yet")
                .. (state.autoEvolve and "  [auto ON]" or "  [auto off]"))
        end
        if U.keepList then
            local keeps = {}
            for name in pairs(state.keepNames) do table.insert(keeps, name) end
            table.sort(keeps)
            U.keepList:SetText("keep list: " .. (#keeps > 0 and table.concat(keeps, ", ") or "none")
                .. "   ranking: " .. state.rankMode .. "   floor: " .. state.sellBelow)
        end
        local c = cap()
        local pn, tn = itemTotal("pets"), itemTotal("trails")
        if U.petBar then U.petBar:Set(c > 0 and math.min(pn / c, 1) * 100 or 0) end
        if U.petCap then
            U.petCap:SetText(string.format("pets %d/%d  (%d free)", pn, c, math.max(c - pn, 0)))
        end
        if U.trailBar then U.trailBar:Set(c > 0 and math.min(tn / c, 1) * 100 or 0) end
        if U.trailCap then
            U.trailCap:SetText(string.format("trails %d/%d  (%d free)", tn, c, math.max(c - tn, 0)))
        end
        end)
        if not ok and not warned then
            warned = true
            warn("[LOS Autofarm v5] refresh error: " .. tostring(err))
        end
    end)
    window:Give(conn)
end

print("[LOS Autofarm v5 | UI LIB] loaded. Window Id 'los-autofarm-v5'. "
    .. "Hatch = openCrystalRemote:InvokeServer(\"openCrystal\", \"<Name> Crystal\"). "
    .. #crystals .. " crystals, "
    .. (function() local n = 0 for _ in pairs(priceOf) do n = n + 1 end return n end)()
    .. " priced pets/trails.")

Features & Information

Legends of speed autofarm made inside Project UAI. Written by deepseek v4.1 flash.

Community Comments (0)

No comments yet for this script.

More Scripts for Legends Of Speed ⚡