VoidAI Self Learning AI
Key RequiredUniversal

VoidAI Self Learning AI

Universal Scripttazgamerz7867tazgamerz7867 1.3K views 391 executesUpdated May 10, 2026
Unrated
00
Lua Script•5689 lines (199538 chars)
--[[
    VoidAI - Self Learning Roblox AI Companion
    Version: 1.0.0
    Author: Taz / TheStrongestOfTomorrow
    
    Features:
    - Observer Mode (learns movement, UI, sequences)
    - Per-universe brain files with per-place entries
    - Ghost Mode (client-side AI playback)
    - Lend Mode (AI takes full input control)
    - LLM Mode (Claude / ChatGPT / Gemini with brain context)
    - GUI Panel with minimize to draggable button
    - PC Keybinds (rebindable) + Mobile floating buttons
    - Platform auto-detection
    - Hotkey/tap reclaim control
    - AI Confidence Meter
    - Session Replay Viewer
    - Persona Profiles
    - Stats Dashboard
    - Stealth Mode
    - Schedule Mode
    - Threat Detection Mode
    - Mental Map
    - Adaptive Learning Cooldown
    - Place Transition Continuity
    - Base Knowledge embedded
    - Universal Executor Compatibility
    - LLM gate (requires trained brain)
]]

-- ============================================================
-- SECTION 1: SERVICES
-- ============================================================
local Players            = game:GetService("Players")
local RunService         = game:GetService("RunService")
local UserInputService   = game:GetService("UserInputService")
local TweenService       = game:GetService("TweenService")
local TeleportService    = game:GetService("TeleportService")
local HttpService        = game:GetService("HttpService")
local GuiService         = game:GetService("GuiService")
local StarterGui         = game:GetService("StarterGui")

local LocalPlayer        = Players.LocalPlayer
local PlayerGui          = LocalPlayer:WaitForChild("PlayerGui")
local Camera             = workspace.CurrentCamera

-- ============================================================
-- SECTION 2: PLATFORM DETECTION
-- ============================================================
local isMobile = UserInputService.TouchEnabled and not UserInputService.KeyboardEnabled
local isPC     = not isMobile

-- ============================================================
-- SECTION 3: EXECUTOR COMPATIBILITY LAYER
-- ============================================================
local Exec = {}

-- File system
function Exec.readFile(path)
    if readfile then
        local ok, result = pcall(readfile, path)
        if ok then return result end
    end
    return nil
end

function Exec.writeFile(path, data)
    if writefile then
        pcall(writefile, path, data)
        return true
    end
    return false
end

function Exec.appendFile(path, data)
    if appendfile then
        pcall(appendfile, path, data)
        return true
    elseif writefile then
        local existing = Exec.readFile(path) or ""
        pcall(writefile, path, existing .. data)
        return true
    end
    return false
end

function Exec.isFile(path)
    if isfile then
        local ok, result = pcall(isfile, path)
        return ok and result
    elseif readfile then
        local ok = pcall(readfile, path)
        return ok
    end
    return false
end

function Exec.makeFolder(path)
    if makefolder then
        if isfolder then
            local ok, exists = pcall(isfolder, path)
            if not (ok and exists) then
                pcall(makefolder, path)
            end
        else
            pcall(makefolder, path)
        end
    end
end

function Exec.isFolder(path)
    if isfolder then
        local ok, result = pcall(isfolder, path)
        return ok and result
    end
    return false
end

function Exec.listFiles(path)
    if listfiles then
        local ok, result = pcall(listfiles, path)
        if ok then return result end
    end
    return {}
end

-- Input simulation fallback chain
function Exec.mouseClick()
    if mouse1click then
        pcall(mouse1click)
    elseif Input and Input.click then
        pcall(Input.click)
    end
end

function Exec.mouseMove(x, y)
    if mousemoveabs then
        pcall(mousemoveabs, x, y)
    elseif mousemoverel then
        pcall(mousemoverel, x, y)
    end
end

function Exec.keyPress(keycode)
    if keypress then
        pcall(keypress, keycode)
    elseif Input and Input.keypress then
        pcall(Input.keypress, keycode)
    end
end

function Exec.keyRelease(keycode)
    if keyrelease then
        pcall(keyrelease, keycode)
    elseif Input and Input.keyrelease then
        pcall(Input.keyrelease, keycode)
    end
end

function Exec.fireButton(button)
    -- Universal UI click fallback - works on all executors + mobile
    if button and button:IsA("GuiButton") then
        pcall(function()
            button:FireMouseButton1Click()
        end)
    end
end

-- HTTP for LLM calls
function Exec.httpPost(url, headers, body)
    -- Try syn.request first (Synapse), then request (most executors), then HttpService
    if syn and syn.request then
        local ok, res = pcall(syn.request, {
            Url = url, Method = "POST",
            Headers = headers, Body = body
        })
        if ok then return res end
    end
    if request then
        local ok, res = pcall(request, {
            Url = url, Method = "POST",
            Headers = headers, Body = body
        })
        if ok then return res end
    end
    if http and http.request then
        local ok, res = pcall(http.request, {
            Url = url, Method = "POST",
            Headers = headers, Body = body
        })
        if ok then return res end
    end
    -- Fallback: HttpService (limited but better than nothing)
    local ok, res = pcall(function()
        return HttpService:RequestAsync({
            Url = url, Method = "POST",
            Headers = headers, Body = body
        })
    end)
    if ok then return res end
    return nil
end

-- ============================================================
-- SECTION 4: BASE KNOWLEDGE (EMBEDDED)
-- ============================================================
local BASE_KNOWLEDGE = {
    version = "1.0",
    pc = {
        movement = {
            forward  = Enum.KeyCode.W,
            backward = Enum.KeyCode.S,
            left     = Enum.KeyCode.A,
            right    = Enum.KeyCode.D,
            jump     = Enum.KeyCode.Space,
            sprint   = Enum.KeyCode.LeftShift,
        },
        camera = "MouseDelta",
    },
    mobile = {
        movement = "ThumbstickPosition",
        jump     = "JumpButton",
        camera   = "TouchDelta",
    },
    commonUI = {
        playButton   = {"Play", "Start", "Begin", "PlayButton", "StartButton"},
        exitButton   = {"Exit", "Leave", "Quit", "Back"},
        confirmButton= {"Confirm", "Yes", "OK", "Accept"},
        shopButton   = {"Shop", "Store", "Buy"},
    },
    horrorDefaults = {
        threatKeywords = {
            "Entity", "Monster", "Rush", "Ambush", "Halt",
            "Eyes", "Figure", "Seek", "Screech", "Dupe",
            "Jack", "Hide", "Timothy", "El Goblino",
        },
        safeObjects = {"Locker", "Bed", "Cabinet", "Closet"},
        runTriggers = {"Roar", "Scream", "Chase"},
    }
}

-- ============================================================
-- SECTION 5: BRAIN FILE SYSTEM
-- ============================================================
local BRAIN_FOLDER  = "VoidAI"
local CONFIG_FILE   = "VoidAI/config.json"

local Brain         = {}
local BrainPath     = ""
local ActivePlace   = ""

local function getUniverseId()
    local ok, id = pcall(function()
        return game.GameId
    end)
    return ok and tostring(id) or tostring(game.PlaceId)
end

local function getPlaceId()
    return tostring(game.PlaceId)
end

local function getGameName()
    local ok, name = pcall(function()
        return MarketplaceService:GetProductInfo(game.PlaceId).Name
    end)
    if ok and name then return name end
    return "UnknownGame_" .. game.PlaceId
end

local function getBrainPath()
    return BRAIN_FOLDER .. "/universe_" .. getUniverseId() .. ".json"
end

local function newPlaceEntry(placeName)
    return {
        placeName   = placeName or ("Place_" .. getPlaceId()),
        sessions    = {},
        mentalMap   = {
            landmarks = {},
            safeZones = {},
            dangerZones = {},
            itemSpawns = {},
            exits = {},
        },
        confidence  = {
            movement  = 0,
            ui        = 0,
            sequences = 0,
        },
        topUIButtons    = {},
        totalActions    = 0,
        totalPlaytime   = 0,
        lastUpdated     = os.time(),
    }
end

local function newBrainFile()
    return {
        version       = "1.0",
        universeId    = getUniverseId(),
        baseKnowledge = BASE_KNOWLEDGE,
        places        = {},
        config        = {
            activeProvider = "local",
            apiKeys        = { claude = "", chatgpt = "", gemini = "" },
            activePersona  = "Mirror",
            keybinds       = {
                reclaim   = "F",
                toggleObs = "G",
                toggleLend= "H",
                toggleGUI = "J",
            },
            scheduleEnabled  = false,
            scheduleStartMin = 0,
            scheduleDuration = 30,
            stealthMode      = false,
        },
        totalSessions = 0,
        createdAt     = os.time(),
        lastUpdated   = os.time(),
    }
end

local function saveBrain()
    if not Brain or not BrainPath or BrainPath == "" then return end
    Brain.lastUpdated = os.time()
    local ok, encoded = pcall(HttpService.JSONEncode, HttpService, Brain)
    if ok then
        Exec.writeFile(BrainPath, encoded)
    end
end

local function loadBrain()
    Exec.makeFolder(BRAIN_FOLDER)
    BrainPath = getBrainPath()
    ActivePlace = getPlaceId()

    if Exec.isFile(BrainPath) then
        local raw = Exec.readFile(BrainPath)
        if raw and raw ~= "" then
            local ok, decoded = pcall(HttpService.JSONDecode, HttpService, raw)
            if ok and decoded then
                Brain = decoded
                -- Ensure base knowledge is always up to date
                Brain.baseKnowledge = BASE_KNOWLEDGE
                -- Ensure this place entry exists
                if not Brain.places[ActivePlace] then
                    Brain.places[ActivePlace] = newPlaceEntry()
                end
                return true
            end
        end
    end

    -- New brain
    Brain = newBrainFile()
    Brain.places[ActivePlace] = newPlaceEntry()
    saveBrain()
    return false
end

local function getCurrentPlace()
    if Brain and Brain.places and Brain.places[ActivePlace] then
        return Brain.places[ActivePlace]
    end
    return nil
end

-- ============================================================
-- SECTION 6: CONFIG SYSTEM
-- ============================================================
local Config = {}

local function loadConfig()
    if Exec.isFile(CONFIG_FILE) then
        local raw = Exec.readFile(CONFIG_FILE)
        if raw and raw ~= "" then
            local ok, decoded = pcall(HttpService.JSONDecode, HttpService, raw)
            if ok then
                Config = decoded
                return
            end
        end
    end
    Config = {
        apiKeys       = { claude = "", chatgpt = "", gemini = "" },
        activeProvider= "local",
        activePersona = "Mirror",
        stealthMode   = false,
        keybinds = {
            reclaim    = "F",
            toggleObs  = "G",
            toggleLend = "H",
            toggleGUI  = "J",
        }
    }
end

local function saveConfig()
    local ok, encoded = pcall(HttpService.JSONEncode, HttpService, Config)
    if ok then
        Exec.writeFile(CONFIG_FILE, encoded)
    end
end

-- ============================================================
-- SECTION 7: ADAPTIVE COOLDOWN + FPS MONITOR
-- ============================================================
local FPS           = 60
local LearnCooldown = 0.1
local lastFPSUpdate = 0
local frameCount    = 0

local function updateFPS(dt)
    frameCount += 1
    lastFPSUpdate += dt
    if lastFPSUpdate >= 1 then
        FPS = frameCount
        frameCount = 0
        lastFPSUpdate = 0

        -- Adaptive cooldown based on FPS
        if FPS > 50 then
            LearnCooldown = 0.1       -- High end: fast learning
        elseif FPS >= 30 then
            LearnCooldown = 0.3       -- Mid range: balanced
        elseif FPS >= 15 then
            LearnCooldown = 0.7       -- Low end: stable
        else
            LearnCooldown = 1.5       -- Potato mode: survive
        end
    end
end

-- ============================================================
-- SECTION 8: AI STATE
-- ============================================================
local AIState = {
    mode            = "idle",     -- idle | observing | ghost | lend | llm
    isLearning      = false,
    isPaused        = false,
    isStealth       = false,
    persona         = "Mirror",   -- Mirror | Aggressive | Passive
    currentSession  = nil,
    sessionStart    = 0,
    lastLearnTick   = 0,
    lastSaveTick    = 0,
    tickCounter     = 0,          -- For throttling low-priority sampling
    llmProvider     = "gemini",
    llmCooldown     = 0,
    threatDetected  = false,
    threatCooldown  = 0,
    reclaimPending  = false,
    scheduleTimer   = 0,
    scheduleActive  = false,
}

local PERSONAS = {
    Mirror     = { aggressionBias = 0.5, grindBias = 0.5, explorationBias = 0.5 },
    Aggressive = { aggressionBias = 0.9, grindBias = 0.3, explorationBias = 0.7 },
    Passive    = { aggressionBias = 0.1, grindBias = 0.9, explorationBias = 0.3 },
}

-- ============================================================
-- SECTION 9: OBSERVER - DATA RECORDING
-- ============================================================
local Observer = {}
Observer.lastPosition = Vector3.new(0,0,0)
Observer.pendingBatch = {}
Observer.batchFlushInterval = 30  -- seconds

function Observer.startSession()
    AIState.currentSession = {
        id         = os.time(),
        placeId    = getPlaceId(),
        starred    = false,
        priority   = 1,
        actions    = {},
        movements  = {},
        uiClicks   = {},
        sequences  = {},
        startTime  = os.time(),
        endTime    = nil,
        notes      = "",
    }
    AIState.sessionStart = os.time()
end

function Observer.endSession()
    if not AIState.currentSession then return end
    AIState.currentSession.endTime = os.time()

    local place = getCurrentPlace()
    if place then
        table.insert(place.sessions, AIState.currentSession)
        place.totalPlaytime += (os.time() - AIState.sessionStart)
        Observer.updateConfidence(place)
        saveBrain()
    end
    AIState.currentSession = nil
end

function Observer.recordMovement(pos)
    if not AIState.currentSession then return end
    local delta = (pos - Observer.lastPosition).Magnitude
    if delta < 2 then return end -- Ignore micro-movements
    Observer.lastPosition = pos

    local entry = {
        pos = { x = pos.X, y = pos.Y, z = pos.Z },
        t   = os.time() - AIState.sessionStart,
    }
    table.insert(AIState.currentSession.movements, entry)
    table.insert(AIState.currentSession.actions, { type = "move", data = entry })

    -- Update mental map
    Observer.updateMentalMap(pos)
end

function Observer.recordUIClick(buttonName, buttonPath)
    if not AIState.currentSession then return end
    local entry = {
        name = buttonName,
        path = buttonPath or "",
        t    = os.time() - AIState.sessionStart,
    }
    table.insert(AIState.currentSession.uiClicks, entry)
    table.insert(AIState.currentSession.actions, { type = "ui_click", data = entry })

    -- Update top UI buttons tally
    local place = getCurrentPlace()
    if place then
        place.topUIButtons[buttonName] = (place.topUIButtons[buttonName] or 0) + 1
        place.totalActions += 1
    end
end

function Observer.recordSequence(label)
    if not AIState.currentSession then return end
    table.insert(AIState.currentSession.sequences, {
        label = label,
        t     = os.time() - AIState.sessionStart,
        actionIndex = #AIState.currentSession.actions,
    })
end

function Observer.updateConfidence(place)
    local sessions = place.sessions
    local n = #sessions
    if n == 0 then return end

    local totalMoves, totalUI, totalSeq = 0, 0, 0
    for _, s in ipairs(sessions) do
        totalMoves += #(s.movements or {})
        totalUI    += #(s.uiClicks  or {})
        totalSeq   += #(s.sequences or {})
    end

    -- Confidence grows logarithmically, capped at 100
    local moveCap = math.min(100, math.floor(math.log(totalMoves + 1) * 15))
    local uiCap   = math.min(100, math.floor(math.log(totalUI    + 1) * 20))
    local seqCap  = math.min(100, math.floor(math.log(totalSeq   + 1) * 25))

    place.confidence.movement  = moveCap
    place.confidence.ui        = uiCap
    place.confidence.sequences = seqCap
end

function Observer.updateMentalMap(pos)
    local place = getCurrentPlace()
    if not place then return end

    -- Only update every 10 ticks (very low priority)
    if AIState.tickCounter % 10 ~= 0 then return end

    local map = place.mentalMap
    local posKey = math.floor(pos.X/10) .. "_" .. math.floor(pos.Z/10)

    if not map.landmarks[posKey] then
        map.landmarks[posKey] = {
            pos   = { x = pos.X, y = pos.Y, z = pos.Z },
            tag   = "visited",
            visits= 1,
        }
    else
        map.landmarks[posKey].visits += 1
        -- High visit count = likely safe zone
        if map.landmarks[posKey].visits > 5 then
            map.landmarks[posKey].tag = "safe"
        end
    end
end

function Observer.tagCurrentZone(tag)
    local char = LocalPlayer.Character
    if not char then return end
    local hrp = char:FindFirstChild("HumanoidRootPart")
    if not hrp then return end
    local pos = hrp.Position
    local place = getCurrentPlace()
    if not place then return end

    local posKey = math.floor(pos.X/10) .. "_" .. math.floor(pos.Z/10)
    if not place.mentalMap.landmarks[posKey] then
        place.mentalMap.landmarks[posKey] = {
            pos = { x = pos.X, y = pos.Y, z = pos.Z },
            tag = tag, visits = 1,
        }
    else
        place.mentalMap.landmarks[posKey].tag = tag
    end
end

-- ============================================================
-- SECTION 10: THREAT DETECTION
-- ============================================================
local ThreatDetector = {}

function ThreatDetector.scan()
    local place = getCurrentPlace()
    local keywords = BASE_KNOWLEDGE.horrorDefaults.threatKeywords
    -- Extend with any game-specific keywords from brain
    if place and place.customThreatKeywords then
        for _, kw in ipairs(place.customThreatKeywords) do
            table.insert(keywords, kw)
        end
    end

    -- Scan workspace for threat entities by name
    for _, obj in ipairs(workspace:GetDescendants()) do
        local name = obj.Name:lower()
        for _, kw in ipairs(keywords) do
            if name:find(kw:lower()) then
                return true, obj.Name
            end
        end
    end

    -- Scan for players sprinting (3+ players suddenly running = threat)
    local runningCount = 0
    for _, p in ipairs(Players:GetPlayers()) do
        if p ~= LocalPlayer and p.Character then
            local hum = p.Character:FindFirstChildOfClass("Humanoid")
            if hum and hum.WalkSpeed > 20 then
                runningCount += 1
            end
        end
    end
    if runningCount >= 2 then
        return true, "MultiPlayerFlee"
    end

    return false, nil
end

function ThreatDetector.respondToThreat(threatName)
    AIState.threatDetected = true
    AIState.threatCooldown = 8  -- seconds of threat mode

    -- Tag current zone as danger
    Observer.tagCurrentZone("danger")

    -- Log threat in session
    if AIState.currentSession then
        table.insert(AIState.currentSession.actions, {
            type = "threat_detected",
            data = { name = threatName, t = os.time() - AIState.sessionStart }
        })
    end

    -- If in lend/ghost mode, switch to survival behavior
    if AIState.mode == "lend" or AIState.mode == "ghost" then
        GhostController.runSurvivalPattern()
    end
end

-- ============================================================
-- SECTION 11: GHOST / LEND CONTROLLER
-- ============================================================
local GhostController = {}
GhostController.replayIndex = 1
GhostController.replaySession = nil
GhostController.inputDisabled = false

function GhostController.pickBestSession()
    local place = getCurrentPlace()
    if not place or #place.sessions == 0 then return nil end

    -- Prioritize starred sessions, then by action count
    local best = nil
    local bestScore = -1
    for _, s in ipairs(place.sessions) do
        local score = #(s.actions or {}) * (s.priority or 1)
        if s.starred then score = score * 2 end
        if score > bestScore then
            bestScore = score
            best = s
        end
    end
    return best
end

-- Ghost avatar reference
GhostController.ghostModel = nil

function GhostController.spawnGhostAvatar()
    -- Destroy old ghost if any
    if GhostController.ghostModel then
        pcall(function() GhostController.ghostModel:Destroy() end)
        GhostController.ghostModel = nil
    end

    local char = LocalPlayer.Character
    if not char then return end

    -- Clone the player's character model
    local ghost = char:Clone()
    ghost.Name = "VoidAI_Ghost"

    -- Remove scripts, animate, humanoid state changes that could error
    for _, v in ipairs(ghost:GetDescendants()) do
        if v:IsA("Script") or v:IsA("LocalScript") or v:IsA("ModuleScript") then
            v:Destroy()
        end
    end

    -- Make every part semi-transparent ghostly blue
    for _, part in ipairs(ghost:GetDescendants()) do
        if part:IsA("BasePart") then
            part.Transparency  = 0.55
            part.CanCollide    = false
            part.Anchored      = false
            part.CastShadow    = false
            -- Tint blue-white
            part.Color         = Color3.fromRGB(140, 180, 255)
            part.Material      = Enum.Material.Neon
        end
        -- Hide accessories/hats being weird
        if part:IsA("Decal") or part:IsA("SpecialMesh") then
            -- keep them, just let transparency handle it
        end
    end

    -- Put the ghost near the player
    local hrp = char:FindFirstChild("HumanoidRootPart")
    local ghostHRP = ghost:FindFirstChild("HumanoidRootPart")
    if hrp and ghostHRP then
        ghostHRP.CFrame = hrp.CFrame * CFrame.new(3, 0, 0)
    end

    -- Give it a name tag
    local billboard = Instance.new("BillboardGui")
    billboard.Size         = UDim2.new(0, 80, 0, 20)
    billboard.StudsOffset  = Vector3.new(0, 3, 0)
    billboard.AlwaysOnTop  = false
    billboard.Parent       = ghostHRP or ghost.PrimaryPart or ghost:FindFirstChildOfClass("BasePart")
    local tag = Instance.new("TextLabel")
    tag.Size               = UDim2.new(1,0,1,0)
    tag.BackgroundTransparency = 1
    tag.Text               = "👻 VoidAI"
    tag.TextColor3         = Color3.fromRGB(160, 200, 255)
    tag.Font               = Enum.Font.GothamBold
    tag.TextSize           = 11
    tag.Parent             = billboard

    ghost.Parent = workspace
    GhostController.ghostModel = ghost
    return ghost
end

function GhostController.destroyGhostAvatar()
    if GhostController.ghostModel then
        pcall(function() GhostController.ghostModel:Destroy() end)
        GhostController.ghostModel = nil
    end
end

function GhostController.startGhost()
    GhostController.replaySession = GhostController.pickBestSession()
    if not GhostController.replaySession then
        VoidGUI.notify("No sessions to replay. Train the AI first!")
        return false
    end
    GhostController.replayIndex = 1
    AIState.mode = "ghost"
    -- Spawn the ghost avatar
    GhostController.spawnGhostAvatar()
    VoidGUI.notify("👻 Ghost spawned!")
    return true
end

function GhostController.startLend()
    GhostController.replaySession = GhostController.pickBestSession()
    if not GhostController.replaySession then
        VoidGUI.notify("No sessions to replay. Train the AI first!")
        return false
    end
    GhostController.replayIndex = 1
    GhostController.inputDisabled = true
    AIState.mode = "lend"
    -- Disable player input
    if isMobile then
        -- On mobile we directly control HumanoidRootPart, nothing to disable
    else
        -- Block keyboard/mouse by consuming input
        UserInputService.InputBegan:Connect(function(input, processed)
            if GhostController.inputDisabled and not AIState.reclaimPending then
                -- Allow reclaim key through
            end
        end)
    end
    return true
end

function GhostController.reclaim()
    GhostController.inputDisabled = false
    AIState.mode = "idle"
    AIState.reclaimPending = true
    GhostController.destroyGhostAvatar()
    VoidGUI.notify("Control reclaimed!")
    task.wait(0.1)
    AIState.reclaimPending = false
end

function GhostController.stepReplay()
    if not GhostController.replaySession then return end
    local actions = GhostController.replaySession.actions
    if not actions or #actions == 0 then return end

    if GhostController.replayIndex > #actions then
        GhostController.replayIndex = 1  -- Loop
        return
    end

    local action = actions[GhostController.replayIndex]
    GhostController.replayIndex += 1

    if action.type == "move" then
        GhostController.executeMove(action.data)
    elseif action.type == "ui_click" then
        GhostController.executeUIClick(action.data)
    end
end

function GhostController.executeMove(data)
    local targetPos = Vector3.new(data.pos.x, data.pos.y, data.pos.z)

    -- In ghost mode: move the ghost avatar, NOT the real player
    if AIState.mode == "ghost" and GhostController.ghostModel then
        local ghost    = GhostController.ghostModel
        local ghostHRP = ghost:FindFirstChild("HumanoidRootPart")
        local ghostHum = ghost:FindFirstChildOfClass("Humanoid")
        if ghostHRP and ghostHum then
            local speed = 16
            if AIState.persona == "Aggressive" then speed = 24
            elseif AIState.persona == "Passive"    then speed = 14 end
            ghostHum.WalkSpeed = speed
            ghostHum:MoveTo(targetPos)
        elseif ghostHRP then
            -- Fallback: lerp CFrame if humanoid is gone
            ghostHRP.CFrame = CFrame.new(targetPos)
        end
        return
    end

    -- Lend / LLM mode: move the real character
    local char = LocalPlayer.Character
    if not char then return end
    local hrp = char:FindFirstChild("HumanoidRootPart")
    local hum = char:FindFirstChildOfClass("Humanoid")
    if not hrp or not hum then return end

    if AIState.persona == "Aggressive" then
        hum.WalkSpeed = 24
    elseif AIState.persona == "Passive" then
        hum.WalkSpeed = 14
    else
        hum.WalkSpeed = 16
    end

    hum:MoveTo(targetPos)
end

function GhostController.executeUIClick(data)
    -- Search PlayerGui for button by name
    local function searchGUI(parent, targetName)
        for _, child in ipairs(parent:GetDescendants()) do
            if child:IsA("GuiButton") and child.Name == targetName then
                return child
            end
        end
        return nil
    end

    local button = searchGUI(PlayerGui, data.name)
    if button then
        Exec.fireButton(button)
    end
end

function GhostController.runSurvivalPattern()
    local char = LocalPlayer.Character
    if not char then return end
    local hrp = char:FindFirstChild("HumanoidRootPart")
    local hum = char:FindFirstChildOfClass("Humanoid")
    if not hrp or not hum then return end

    -- Find nearest tagged safe zone from mental map
    local place = getCurrentPlace()
    if not place then return end

    local nearest = nil
    local nearestDist = math.huge

    for _, landmark in pairs(place.mentalMap.landmarks) do
        if landmark.tag == "safe" then
            local lpos = Vector3.new(landmark.pos.x, landmark.pos.y, landmark.pos.z)
            local dist = (hrp.Position - lpos).Magnitude
            if dist < nearestDist then
                nearestDist = dist
                nearest = lpos
            end
        end
    end

    if nearest then
        hum.WalkSpeed = 24  -- Sprint to safety
        hum:MoveTo(nearest)
    else
        -- No safe zone known: run away from danger direction
        hum.WalkSpeed = 24
        local runDir = hrp.CFrame.LookVector * -1
        hum:MoveTo(hrp.Position + runDir * 20)
    end
end

-- ============================================================
-- SECTION 12: LLM CONTROLLER
-- ============================================================
local LLMController = {}
LLMController.isWaiting = false
LLMController.callCooldown = 3  -- seconds between LLM calls

local LLM_PROVIDERS = {
    claude = {
        url   = "https://api.anthropic.com/v1/messages",
        model = "claude-opus-4-5",
        buildHeaders = function(key)
            return {
                ["Content-Type"]      = "application/json",
                ["x-api-key"]         = key,
                ["anthropic-version"] = "2023-06-01",
            }
        end,
        buildBody = function(prompt)
            return HttpService:JSONEncode({
                model      = "claude-opus-4-5",
                max_tokens = 512,
                messages   = {{ role = "user", content = prompt }}
            })
        end,
        parseResponse = function(body)
            local ok, decoded = pcall(HttpService.JSONDecode, HttpService, body)
            if ok and decoded.content and decoded.content[1] then
                return decoded.content[1].text
            end
            return nil
        end,
    },
    chatgpt = {
        url   = "https://api.openai.com/v1/chat/completions",
        model = "gpt-4o",
        buildHeaders = function(key)
            return {
                ["Content-Type"]  = "application/json",
                ["Authorization"] = "Bearer " .. key,
            }
        end,
        buildBody = function(prompt)
            return HttpService:JSONEncode({
                model    = "gpt-4o",
                messages = {{ role = "user", content = prompt }},
                max_tokens = 512,
            })
        end,
        parseResponse = function(body)
            local ok, decoded = pcall(HttpService.JSONDecode, HttpService, body)
            if ok and decoded.choices and decoded.choices[1] then
                return decoded.choices[1].message.content
            end
            return nil
        end,
    },
    gemini = {
        url   = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
        model = "gemini-pro",
        buildHeaders = function(key)
            return {
                ["Content-Type"]  = "application/json",
                ["x-goog-api-key"]= key,
            }
        end,
        buildBody = function(prompt)
            return HttpService:JSONEncode({
                contents = {{ parts = {{ text = prompt }} }}
            })
        end,
        parseResponse = function(body)
            local ok, decoded = pcall(HttpService.JSONDecode, HttpService, body)
            if ok and decoded.candidates and decoded.candidates[1] then
                local parts = decoded.candidates[1].content.parts
                if parts and parts[1] then
                    return parts[1].text
                end
            end
            return nil
        end,
    },
}

function LLMController.canUseLLM()
    local place = getCurrentPlace()
    if not place then return false, "No brain loaded" end
    if not place.sessions or #place.sessions == 0 then
        return false, "No sessions trained for this game"
    end
    local totalActions = 0
    for _, s in ipairs(place.sessions) do
        totalActions += #(s.actions or {})
    end
    if totalActions == 0 then
        return false, "Sessions exist but no actions recorded"
    end
    local provider = Config.activeProvider
    if provider == "local" then return false, "No LLM provider selected" end
    local key = Config.apiKeys[provider]
    if not key or key == "" then
        return false, "No API key for " .. provider
    end
    return true, nil
end

function LLMController.buildPrompt(gameState)
    local place = getCurrentPlace()
    if not place then return nil end

    local safeZones, dangerZones = {}, {}
    for _, lm in pairs(place.mentalMap.landmarks) do
        if lm.tag == "safe" then
            table.insert(safeZones, string.format("(%.0f,%.0f,%.0f)", lm.pos.x, lm.pos.y, lm.pos.z))
        elseif lm.tag == "danger" then
            table.insert(dangerZones, string.format("(%.0f,%.0f,%.0f)", lm.pos.x, lm.pos.y, lm.pos.z))
        end
    end

    local topUI = {}
    for name, count in pairs(place.topUIButtons) do
        table.insert(topUI, name .. "(" .. count .. "x)")
    end

    return string.format([[
You are an AI controlling a Roblox character in %s.
You have been trained by a human player and know this game.

GAME KNOWLEDGE:
- Sessions recorded: %d
- Total actions learned: %d
- Known safe zones: %s
- Known danger zones: %s
- Frequently clicked UI: %s
- Confidence: Movement %d%%, UI %d%%, Sequences %d%%
- Active persona: %s

CURRENT SITUATION:
- Character position: (%.1f, %.1f, %.1f)
- Nearby players: %d
- Threat detected: %s
- Current mode: %s

Respond ONLY with a JSON object. No explanation. No markdown. Just raw JSON.
Use this exact format:
{"action":"move","direction":"forward","duration":1.0,"next":null}

Available actions: move, jump, sprint, click_ui, wait, hide, run_to_safety
For click_ui, add "target":"ButtonName"
For move, direction can be: forward, backward, left, right, or "to_safe_zone"
]],
        place.placeName,
        #place.sessions,
        place.totalActions,
        table.concat(safeZones, ", "),
        table.concat(dangerZones, ", "),
        table.concat(topUI, ", "),
        place.confidence.movement,
        place.confidence.ui,
        place.confidence.sequences,
        AIState.persona,
        gameState.pos.x, gameState.pos.y, gameState.pos.z,
        gameState.nearbyPlayers,
        tostring(gameState.threatDetected),
        AIState.mode
    )
end

function LLMController.parseAction(responseText)
    if not responseText then return nil end
    -- Strip any accidental markdown
    local clean = responseText:gsub("```json", ""):gsub("```", ""):match("^%s*(.-)%s*$")
    local ok, action = pcall(HttpService.JSONDecode, HttpService, clean)
    if ok and action and action.action then
        return action
    end
    return nil
end

function LLMController.executeAction(action)
    if not action then return end
    local char = LocalPlayer.Character
    if not char then return end
    local hrp = char:FindFirstChild("HumanoidRootPart")
    local hum = char:FindFirstChildOfClass("Humanoid")

    if action.action == "move" then
        if hrp and hum then
            local dir = action.direction
            local moveVec = Vector3.new(0,0,0)
            if dir == "forward"    then moveVec = hrp.CFrame.LookVector
            elseif dir == "backward" then moveVec = hrp.CFrame.LookVector * -1
            elseif dir == "left"     then moveVec = hrp.CFrame.RightVector * -1
            elseif dir == "right"    then moveVec = hrp.CFrame.RightVector
            elseif dir == "to_safe_zone" then
                GhostController.runSurvivalPattern()
                return
            end
            hum:MoveTo(hrp.Position + moveVec * 10)
        end
    elseif action.action == "jump" then
        if hum then hum.Jump = true end
    elseif action.action == "sprint" then
        if hum then hum.WalkSpeed = 24 end
    elseif action.action == "click_ui" then
        GhostController.executeUIClick({ name = action.target or "" })
    elseif action.action == "hide" then
        -- Find nearest safe object (locker/cabinet)
        for _, obj in ipairs(workspace:GetDescendants()) do
            for _, safeObj in ipairs(BASE_KNOWLEDGE.horrorDefaults.safeObjects) do
                if obj.Name:find(safeObj) and obj:IsA("BasePart") then
                    if hum then hum:MoveTo(obj.Position) end
                    return
                end
            end
        end
    elseif action.action == "run_to_safety" then
        GhostController.runSurvivalPattern()
    elseif action.action == "wait" then
        task.wait(action.duration or 1)
    end
end

function LLMController.callLLM()
    if LLMController.isWaiting then return end
    local canUse, reason = LLMController.canUseLLM()
    if not canUse then
        VoidGUI.notify("LLM unavailable: " .. (reason or "unknown"))
        AIState.mode = "idle"
        return
    end

    local char = LocalPlayer.Character
    local hrp = char and char:FindFirstChild("HumanoidRootPart")
    local pos = hrp and hrp.Position or Vector3.new(0,0,0)

    local nearby = 0
    for _, p in ipairs(Players:GetPlayers()) do
        if p ~= LocalPlayer and p.Character then
            local phrp = p.Character:FindFirstChild("HumanoidRootPart")
            if phrp and (phrp.Position - pos).Magnitude < 50 then
                nearby += 1
            end
        end
    end

    local gameState = {
        pos            = { x = pos.X, y = pos.Y, z = pos.Z },
        nearbyPlayers  = nearby,
        threatDetected = AIState.threatDetected,
    }

    local prompt = LLMController.buildPrompt(gameState)
    if not prompt then return end

    local provider = Config.activeProvider
    local prov = LLM_PROVIDERS[provider]
    if not prov then return end

    LLMController.isWaiting = true

    task.spawn(function()
        local headers = prov.buildHeaders(Config.apiKeys[provider])
        local body    = prov.buildBody(prompt)
        local response = Exec.httpPost(prov.url, headers, body)

        if response and response.Body then
            local text = prov.parseResponse(response.Body)
            local action = LLMController.parseAction(text)
            if action then
                LLMController.executeAction(action)
            else
                -- Fallback to local AI
                GhostController.stepReplay()
            end
        else
            -- No response: fallback to local
            GhostController.stepReplay()
        end

        LLMController.isWaiting = false
    end)
end

-- ============================================================
-- SECTION 13: PLACE TRANSITION HANDLING
-- ============================================================
local function handlePlaceTransition()
    -- Save current session before transition
    if AIState.isLearning and AIState.currentSession then
        Observer.endSession()
    end
    saveBrain()

    -- Wait for new place to load
    task.wait(2)

    -- Re-initialize brain for new place
    local newPlaceId = getPlaceId()
    ActivePlace = newPlaceId

    if not Brain.places[ActivePlace] then
        Brain.places[ActivePlace] = newPlaceEntry()
    end

    if AIState.isLearning then
        Observer.startSession()
    end

    VoidGUI.notify("Transitioned to new place. Brain updated.")
    VoidGUI.updateDisplay()
end

-- Listen for teleport arrivals
pcall(function()
    TeleportService.LocalPlayerArrivedFromTeleport:Connect(function()
        handlePlaceTransition()
    end)
end)

-- ============================================================
-- SECTION 14: UI EVENT HOOKS (Event-driven, zero polling cost)
-- ============================================================
local UIHooks = {}

local function hookUIClicks(parent)
    for _, obj in ipairs(parent:GetDescendants()) do
        if obj:IsA("GuiButton") then
            obj.MouseButton1Click:Connect(function()
                if AIState.isLearning and AIState.currentSession then
                    Observer.recordUIClick(obj.Name, obj:GetFullName())
                end
            end)
        end
    end
    parent.DescendantAdded:Connect(function(obj)
        if obj:IsA("GuiButton") then
            obj.MouseButton1Click:Connect(function()
                if AIState.isLearning and AIState.currentSession then
                    Observer.recordUIClick(obj.Name, obj:GetFullName())
                end
            end)
        end
    end)
end

-- Hook existing GUI
task.spawn(function()
    task.wait(2)
    for _, gui in ipairs(PlayerGui:GetChildren()) do
        if gui:IsA("ScreenGui") and gui.Name ~= "VoidAIGui" then
            hookUIClicks(gui)
        end
    end
    PlayerGui.ChildAdded:Connect(function(gui)
        if gui:IsA("ScreenGui") and gui.Name ~= "VoidAIGui" then
            hookUIClicks(gui)
        end
    end)
end)

-- ============================================================
-- SECTION 15: KEYBIND SYSTEM (PC + Rebindable)
-- ============================================================
local Keybinds = {}
Keybinds.binds = {
    reclaim    = Enum.KeyCode.F,
    toggleObs  = Enum.KeyCode.G,
    toggleLend = Enum.KeyCode.H,
    toggleGUI  = Enum.KeyCode.J,
}

local KEY_MAP = {
    F = Enum.KeyCode.F, G = Enum.KeyCode.G, H = Enum.KeyCode.H,
    J = Enum.KeyCode.J, K = Enum.KeyCode.K, L = Enum.KeyCode.L,
    Z = Enum.KeyCode.Z, X = Enum.KeyCode.X, C = Enum.KeyCode.C,
    V = Enum.KeyCode.V, B = Enum.KeyCode.B, N = Enum.KeyCode.N,
    M = Enum.KeyCode.M, R = Enum.KeyCode.R, T = Enum.KeyCode.T,
    Y = Enum.KeyCode.Y, U = Enum.KeyCode.U, I = Enum.KeyCode.I,
    O = Enum.KeyCode.O, P = Enum.KeyCode.P,
}

function Keybinds.setKey(action, keyName)
    if KEY_MAP[keyName] then
        Keybinds.binds[action] = KEY_MAP[keyName]
        if Config.keybinds then
            Config.keybinds[action] = keyName
            saveConfig()
        end
    end
end

if not isMobile then
    UserInputService.InputBegan:Connect(function(input, processed)
        if processed then return end

        if input.KeyCode == Keybinds.binds.reclaim then
            if AIState.mode == "lend" or AIState.mode == "ghost" or AIState.mode == "llm" then
                GhostController.reclaim()
                VoidGUI.updateDisplay()
            end

        elseif input.KeyCode == Keybinds.binds.toggleObs then
            if AIState.isLearning then
                AIState.isLearning = false
                Observer.endSession()
                VoidGUI.notify("Learning stopped")
            else
                AIState.isLearning = true
                Observer.startSession()
                VoidGUI.notify("Learning started")
            end
            VoidGUI.updateDisplay()

        elseif input.KeyCode == Keybinds.binds.toggleLend then
            if AIState.mode == "lend" then
                GhostController.reclaim()
            else
                GhostController.startLend()
            end
            VoidGUI.updateDisplay()

        elseif input.KeyCode == Keybinds.binds.toggleGUI then
            VoidGUI.toggle()
        end
    end)
end

-- ============================================================
-- SECTION 16: SCHEDULE MODE
-- ============================================================
local function updateSchedule(dt)
    if not Config.scheduleEnabled then return end
    AIState.scheduleTimer += dt

    local startSec = (Config.scheduleStartMin or 0) * 60
    local durSec   = (Config.scheduleDuration or 30) * 60

    if not AIState.scheduleActive and AIState.scheduleTimer >= startSec then
        AIState.scheduleActive = true
        GhostController.startLend()
        VoidGUI.notify("Schedule: Lend Mode started")
    end

    if AIState.scheduleActive and AIState.scheduleTimer >= startSec + durSec then
        AIState.scheduleActive = false
        GhostController.reclaim()
        AIState.scheduleTimer = 0
        VoidGUI.notify("Schedule: Session complete")
    end
end

-- ============================================================
-- SECTION 17: MAIN LOOP
-- ============================================================
local lastLearnTick = 0
local lastSaveTick  = 0
local lastThreatTick= 0
local lastLLMTick   = 0

RunService.Heartbeat:Connect(function(dt)
    updateFPS(dt)
    updateSchedule(dt)
    AIState.tickCounter += 1

    -- Threat detection (always runs, every 0.5s)
    lastThreatTick += dt
    if lastThreatTick >= 0.5 then
        lastThreatTick = 0
        local isThreat, tName = ThreatDetector.scan()
        if isThreat and not AIState.threatDetected then
            ThreatDetector.respondToThreat(tName)
        end
        if AIState.threatCooldown > 0 then
            AIState.threatCooldown -= 0.5
            if AIState.threatCooldown <= 0 then
                AIState.threatDetected = false
            end
        end
    end

    -- Observer learning tick (adaptive cooldown)
    lastLearnTick += dt
    if lastLearnTick >= LearnCooldown then
        lastLearnTick = 0

        if AIState.isLearning and AIState.currentSession then
            local char = LocalPlayer.Character
            if char then
                local hrp = char:FindFirstChild("HumanoidRootPart")
                if hrp then
                    Observer.recordMovement(hrp.Position)
                end
            end
        end
    end

    -- Ghost/Lend step (every 0.2s)
    if AIState.mode == "ghost" or AIState.mode == "lend" then
        lastLLMTick += dt
        if lastLLMTick >= 0.2 then
            lastLLMTick = 0
            if not AIState.isPaused then
                GhostController.stepReplay()
            end
        end
    end

    -- LLM step (every LLMController.callCooldown seconds)
    if AIState.mode == "llm" then
        lastLLMTick += dt
        if lastLLMTick >= LLMController.callCooldown then
            lastLLMTick = 0
            if not AIState.isPaused then
                LLMController.callLLM()
            end
        end
    end

    -- Batch save (every 30s)
    lastSaveTick += dt
    if lastSaveTick >= 30 then
        lastSaveTick = 0
        if AIState.isLearning then
            saveBrain()
        end
    end
end)

-- ============================================================
-- SECTION 18: GUI SYSTEM
-- ============================================================
VoidGUI = {}
VoidGUI.isOpen  = true
VoidGUI.tab     = "main"  -- main | stats | replay | settings

local screenGui, mainFrame, miniButton

local COLORS = {
    bg         = Color3.fromRGB(12,  12,  18),
    panel      = Color3.fromRGB(18,  18,  28),
    accent     = Color3.fromRGB(88,  101, 242),
    accentHot  = Color3.fromRGB(120, 130, 255),
    text       = Color3.fromRGB(220, 220, 235),
    textDim    = Color3.fromRGB(120, 120, 145),
    success    = Color3.fromRGB(87,  242, 135),
    warning    = Color3.fromRGB(254, 231, 92),
    danger     = Color3.fromRGB(237, 66,  69),
    border     = Color3.fromRGB(35,  35,  55),
}

local MODE_ICONS = {
    idle       = "🌙",
    observing  = "🧠",
    ghost      = "👻",
    lend       = "🎮",
    llm        = "🤖",
    paused     = "⏸",
    stealth    = "🔇",
}

local function makeDraggable(frame, handle)
    local dragging, dragStart, startPos
    handle = handle or frame
    handle.InputBegan:Connect(function(input)
        if input.UserInputType == Enum.UserInputType.MouseButton1
        or input.UserInputType == Enum.UserInputType.Touch then
            dragging  = true
            dragStart = input.Position
            startPos  = frame.Position
        end
    end)
    -- Use UserInputService so the drag never reaches Roblox camera
    UserInputService.InputChanged:Connect(function(input)
        if not dragging then return end
        if input.UserInputType == Enum.UserInputType.MouseMovement or
           input.UserInputType == Enum.UserInputType.Touch then
            local delta = input.Position - dragStart
            frame.Position = UDim2.new(
                startPos.X.Scale,
                startPos.X.Offset + delta.X,
                startPos.Y.Scale,
                startPos.Y.Offset + delta.Y
            )
        end
    end)
    UserInputService.InputEnded:Connect(function(input)
        if input.UserInputType == Enum.UserInputType.MouseButton1
        or input.UserInputType == Enum.UserInputType.Touch then
            dragging = false
        end
    end)
end

local function makeButton(parent, text, size, pos, color)
    local btn = Instance.new("TextButton")
    btn.Size              = size
    btn.Position          = pos
    btn.BackgroundColor3  = color or COLORS.accent
    btn.TextColor3        = COLORS.text
    btn.Text              = text
    btn.Font              = Enum.Font.GothamBold
    btn.TextSize          = isMobile and 13 or 12
    btn.BorderSizePixel   = 0
    btn.AutoButtonColor   = false
    btn.Parent            = parent
    local corner = Instance.new("UICorner")
    corner.CornerRadius   = UDim.new(0, 6)
    corner.Parent         = btn
    -- Hover effect
    btn.MouseEnter:Connect(function()
        TweenService:Create(btn, TweenInfo.new(0.15), {BackgroundColor3 = COLORS.accentHot}):Play()
    end)
    btn.MouseLeave:Connect(function()
        TweenService:Create(btn, TweenInfo.new(0.15), {BackgroundColor3 = color or COLORS.accent}):Play()
    end)
    return btn
end

local function makeLabel(parent, text, size, pos, textSize, color)
    local lbl = Instance.new("TextLabel")
    lbl.Size              = size
    lbl.Position          = pos
    lbl.BackgroundTransparency = 1
    lbl.TextColor3        = color or COLORS.text
    lbl.Text              = text
    lbl.Font              = Enum.Font.Gotham
    lbl.TextSize          = textSize or 12
    lbl.TextXAlignment    = Enum.TextXAlignment.Left
    lbl.Parent            = parent
    return lbl
end

local function makeBar(parent, pos, value, color)
    local track = Instance.new("Frame")
    track.Size            = UDim2.new(1, -12, 0, 6)
    track.Position        = pos
    track.BackgroundColor3= COLORS.border
    track.BorderSizePixel = 0
    track.Parent          = parent
    local tc = Instance.new("UICorner")
    tc.CornerRadius = UDim.new(0,3)
    tc.Parent = track

    local fill = Instance.new("Frame")
    fill.Size             = UDim2.new(value/100, 0, 1, 0)
    fill.BackgroundColor3 = color or COLORS.accent
    fill.BorderSizePixel  = 0
    fill.Parent           = track
    local fc = Instance.new("UICorner")
    fc.CornerRadius = UDim.new(0,3)
    fc.Parent = fill
    return track, fill
end

function VoidGUI.notify(msg)
    if AIState.isStealth then return end
    -- Simple toast notification
    local toast = Instance.new("TextLabel")
    toast.Size              = UDim2.new(0, 220, 0, 32)
    toast.Position          = UDim2.new(0.5, -110, 0, 60)
    toast.BackgroundColor3  = COLORS.panel
    toast.TextColor3        = COLORS.text
    toast.Text              = msg
    toast.Font              = Enum.Font.Gotham
    toast.TextSize          = 12
    toast.BorderSizePixel   = 0
    toast.ZIndex            = 100
    toast.Parent            = screenGui
    local corner = Instance.new("UICorner")
    corner.CornerRadius = UDim.new(0,8)
    corner.Parent = toast
    TweenService:Create(toast, TweenInfo.new(0.3), {BackgroundTransparency=0}):Play()
    task.delay(2.5, function()
        TweenService:Create(toast, TweenInfo.new(0.3), {BackgroundTransparency=1, TextTransparency=1}):Play()
        task.wait(0.35)
        toast:Destroy()
    end)
end

function VoidGUI.getModeIcon()
    if AIState.isStealth    then return MODE_ICONS.stealth  end
    if AIState.isPaused     then return MODE_ICONS.paused   end
    if AIState.mode == "llm"  then return MODE_ICONS.llm    end
    return MODE_ICONS[AIState.mode] or MODE_ICONS.idle
end

function VoidGUI.getModeLabel()
    if AIState.isStealth    then return "STEALTH"       end
    if AIState.isPaused     then return "PAUSED"        end
    local labels = {
        idle      = "IDLE",
        observing = "OBSERVING",
        ghost     = "GHOST MODE",
        lend      = "LEND MODE",
        llm       = "LLM MODE",
    }
    return labels[AIState.mode] or "IDLE"
end

-- Main GUI labels refs for live updates
local lblMode, lblGame, lblSessions, lblActions, lblFPS
local barMove, barUI, barSeq
local btnLearn, btnGhost, btnLend, btnLLM, btnPause

function VoidGUI.updateDisplay()
    if AIState.isStealth then return end
    local place = getCurrentPlace()
    if lblMode    then lblMode.Text    = VoidGUI.getModeIcon() .. " " .. VoidGUI.getModeLabel() end
    if lblGame    then lblGame.Text    = "Game: " .. (place and place.placeName or "Unknown") end
    if lblSessions then
        lblSessions.Text = "Sessions: " .. (place and #place.sessions or 0)
    end
    if lblActions  then
        lblActions.Text  = "Actions: " .. (place and place.totalActions or 0)
    end
    if lblFPS      then
        lblFPS.Text      = "FPS: " .. FPS .. "  Cooldown: " .. string.format("%.0fms", LearnCooldown * 1000)
    end
    if place then
        local c = place.confidence
        if barMove then barMove.Size = UDim2.new(c.movement/100, 0, 1, 0) end
        if barUI   then barUI.Size   = UDim2.new(c.ui/100,       0, 1, 0) end
        if barSeq  then barSeq.Size  = UDim2.new(c.sequences/100,0, 1, 0) end
    end
    -- Update mini button icon
    if miniButton then
        miniButton.Text = VoidGUI.getModeIcon()
    end
end

function VoidGUI.toggle()
    if mainFrame then
        mainFrame.Visible = not mainFrame.Visible
        if miniButton then
            miniButton.Visible = not mainFrame.Visible
        end
    end
end

function VoidGUI.minimize()
    if mainFrame then
        mainFrame.Visible = false
        if miniButton then miniButton.Visible = true end
    end
end

function VoidGUI.maximize()
    if mainFrame then
        mainFrame.Visible = true
        if miniButton then miniButton.Visible = false end
    end
end

function VoidGUI.buildMainTab(container)
    local y = 0

    -- Mode display
    lblMode = makeLabel(container, "🌙 IDLE",
        UDim2.new(1,-12,0,22), UDim2.new(0,6,0,y), 15, COLORS.accent)
    lblMode.Font = Enum.Font.GothamBold
    y += 26

    lblGame = makeLabel(container, "Game: Loading...",
        UDim2.new(1,-12,0,16), UDim2.new(0,6,0,y), 11, COLORS.textDim)
    y += 18

    -- Divider
    local div = Instance.new("Frame")
    div.Size = UDim2.new(1,-12,0,1)
    div.Position = UDim2.new(0,6,0,y)
    div.BackgroundColor3 = COLORS.border
    div.BorderSizePixel = 0
    div.Parent = container
    y += 8

    lblSessions = makeLabel(container, "Sessions: 0",
        UDim2.new(0.5,-6,0,14), UDim2.new(0,6,0,y), 11)
    lblActions = makeLabel(container, "Actions: 0",
        UDim2.new(0.5,-6,0,14), UDim2.new(0.5,0,0,y), 11)
    y += 18

    lblFPS = makeLabel(container, "FPS: 60  Cooldown: 100ms",
        UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 10, COLORS.textDim)
    y += 20

    -- Confidence bars
    makeLabel(container, "Confidence", UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 11, COLORS.textDim)
    y += 15
    makeLabel(container, "Move", UDim2.new(0,30,0,12), UDim2.new(0,6,0,y), 10, COLORS.textDim)
    local _, mf = makeBar(container, UDim2.new(0,38,0,y+2), 0, COLORS.success)
    barMove = mf
    y += 14
    makeLabel(container, "UI  ", UDim2.new(0,30,0,12), UDim2.new(0,6,0,y), 10, COLORS.textDim)
    local _, uf = makeBar(container, UDim2.new(0,38,0,y+2), 0, COLORS.accent)
    barUI = uf
    y += 14
    makeLabel(container, "Seq ", UDim2.new(0,30,0,12), UDim2.new(0,6,0,y), 10, COLORS.textDim)
    local _, sf = makeBar(container, UDim2.new(0,38,0,y+2), 0, COLORS.warning)
    barSeq = sf
    y += 20

    -- Action buttons
    btnLearn = makeButton(container, "▶ Start Learning",
        UDim2.new(1,-12,0,28), UDim2.new(0,6,0,y), COLORS.success)
    btnLearn.TextColor3 = Color3.fromRGB(20,20,30)
    y += 32

    btnGhost = makeButton(container, "👻 Ghost Mode",
        UDim2.new(1,-12,0,28), UDim2.new(0,6,0,y))
    y += 32

    btnLend = makeButton(container, "🎮 Lend Control",
        UDim2.new(1,-12,0,28), UDim2.new(0,6,0,y), COLORS.warning)
    btnLend.TextColor3 = Color3.fromRGB(20,20,30)
    y += 32

    -- LLM button (gated)
    btnLLM = makeButton(container, "🤖 Lend to LLM",
        UDim2.new(1,-12,0,28), UDim2.new(0,6,0,y), COLORS.panel)
    btnLLM.TextColor3 = COLORS.textDim
    y += 32

    btnPause = makeButton(container, "⏸ Pause",
        UDim2.new(0.5,-9,0,26), UDim2.new(0,6,0,y), COLORS.panel)

    local btnReset = makeButton(container, "🗑 Reset",
        UDim2.new(0.5,-9,0,26), UDim2.new(0.5,3,0,y), COLORS.danger)
    y += 32

    -- Wire up buttons
    btnLearn.MouseButton1Click:Connect(function()
        if AIState.isLearning then
            AIState.isLearning = false
            Observer.endSession()
            btnLearn.Text = "▶ Start Learning"
            VoidGUI.notify("Learning stopped. Brain saved.")
        else
            AIState.isLearning = true
            Observer.startSession()
            btnLearn.Text = "⏹ Stop Learning"
            VoidGUI.notify("Learning started!")
        end
        VoidGUI.updateDisplay()
    end)

    btnGhost.MouseButton1Click:Connect(function()
        if AIState.mode == "ghost" then
            GhostController.reclaim()
            btnGhost.Text = "👻 Ghost Mode"
        else
            if GhostController.startGhost() then
                btnGhost.Text = "⏹ Stop Ghost"
            end
        end
        VoidGUI.updateDisplay()
    end)

    btnLend.MouseButton1Click:Connect(function()
        if AIState.mode == "lend" then
            GhostController.reclaim()
            btnLend.Text = "🎮 Lend Control"
        else
            if GhostController.startLend() then
                btnLend.Text = "⏹ Reclaim"
                if isMobile then
                    VoidGUI.notify("Tap ✋ to reclaim control")
                else
                    VoidGUI.notify("Press " .. (Config.keybinds and Config.keybinds.reclaim or "F") .. " to reclaim")
                end
            end
        end
        VoidGUI.updateDisplay()
    end)

    btnLLM.MouseButton1Click:Connect(function()
        local canUse, reason = LLMController.canUseLLM()
        if not canUse then
            VoidGUI.notify("⚠ " .. (reason or "Train AI first!"))
            return
        end
        if AIState.mode == "llm" then
            GhostController.reclaim()
            btnLLM.Text = "🤖 Lend to LLM"
            btnLLM.BackgroundColor3 = COLORS.panel
            btnLLM.TextColor3 = COLORS.textDim
        else
            AIState.mode = "llm"
            btnLLM.Text = "⏹ Stop LLM"
            btnLLM.BackgroundColor3 = COLORS.accent
            btnLLM.TextColor3 = COLORS.text
            local place = getCurrentPlace()
            if place and place.confidence.movement < 40 then
                VoidGUI.notify("⚠ Low training data. LLM may perform poorly.")
            end
        end
        VoidGUI.updateDisplay()
    end)

    btnPause.MouseButton1Click:Connect(function()
        AIState.isPaused = not AIState.isPaused
        btnPause.Text = AIState.isPaused and "▶ Resume" or "⏸ Pause"
        VoidGUI.notify(AIState.isPaused and "Paused" or "Resumed")
        VoidGUI.updateDisplay()
    end)

    btnReset.MouseButton1Click:Connect(function()
        -- Confirmation inline
        btnReset.Text = "Confirm?"
        task.delay(2, function()
            if btnReset.Text == "Confirm?" then
                btnReset.Text = "🗑 Reset"
            end
        end)
        if btnReset.Text == "Confirm?" then return end
        local place = getCurrentPlace()
        if place then
            place.sessions  = {}
            place.totalActions = 0
            place.confidence = { movement=0, ui=0, sequences=0 }
            saveBrain()
            VoidGUI.notify("Brain reset for this game.")
        end
        btnReset.Text = "🗑 Reset"
        VoidGUI.updateDisplay()
    end)

    -- Update LLM button state periodically
    task.spawn(function()
        while true do
            task.wait(2)
            local canUse = LLMController.canUseLLM()
            if canUse and AIState.mode ~= "llm" then
                btnLLM.BackgroundColor3 = COLORS.accent
                btnLLM.TextColor3 = COLORS.text
            elseif AIState.mode ~= "llm" then
                btnLLM.BackgroundColor3 = COLORS.panel
                btnLLM.TextColor3 = COLORS.textDim
            end
        end
    end)
end

function VoidGUI.buildStatsTab(container)
    local place = getCurrentPlace()
    local y = 0

    makeLabel(container, "📊 Stats Dashboard",
        UDim2.new(1,-12,0,18), UDim2.new(0,6,0,y), 14, COLORS.accent).Font = Enum.Font.GothamBold
    y += 24

    local sessions  = place and #place.sessions or 0
    local actions   = place and place.totalActions or 0
    local playtime  = place and place.totalPlaytime or 0
    local brainSize = 0
    if Exec.isFile(BrainPath) then
        local raw = Exec.readFile(BrainPath)
        brainSize = raw and #raw or 0
    end

    local stats = {
        {"Sessions",        sessions},
        {"Total Actions",   actions},
        {"Playtime",        string.format("%.0fm %.0fs", playtime/60, playtime%60)},
        {"Brain File Size", string.format("%.1f KB", brainSize/1024)},
        {"Current FPS",     FPS},
        {"Learn Cooldown",  string.format("%.0fms", LearnCooldown*1000)},
        {"Universe ID",     getUniverseId()},
        {"Place ID",        getPlaceId()},
    }

    for _, stat in ipairs(stats) do
        makeLabel(container, stat[1], UDim2.new(0.5,-6,0,16), UDim2.new(0,6,0,y), 11, COLORS.textDim)
        makeLabel(container, tostring(stat[2]), UDim2.new(0.5,-6,0,16), UDim2.new(0.5,0,0,y), 11, COLORS.text)
        y += 18
    end

    y += 6
    makeLabel(container, "Top UI Buttons", UDim2.new(1,-12,0,16), UDim2.new(0,6,0,y), 11, COLORS.accent)
    y += 18
    if place then
        local sorted = {}
        for name, count in pairs(place.topUIButtons) do
            table.insert(sorted, {name=name, count=count})
        end
        table.sort(sorted, function(a,b) return a.count > b.count end)
        for i = 1, math.min(5, #sorted) do
            local s = sorted[i]
            makeLabel(container, s.name .. " x" .. s.count,
                UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 10, COLORS.text)
            y += 15
        end
    end
end

function VoidGUI.buildReplayTab(container)
    local y = 0
    makeLabel(container, "📼 Session Replay",
        UDim2.new(1,-12,0,18), UDim2.new(0,6,0,y), 14, COLORS.accent).Font = Enum.Font.GothamBold
    y += 24

    local place = getCurrentPlace()
    if not place or #place.sessions == 0 then
        makeLabel(container, "No sessions recorded yet.",
            UDim2.new(1,-12,0,20), UDim2.new(0,6,0,y), 11, COLORS.textDim)
        return
    end

    -- Scrollable session list
    local scroll = Instance.new("ScrollingFrame")
    scroll.Size              = UDim2.new(1,0,0,180)
    scroll.Position          = UDim2.new(0,0,0,y)
    scroll.BackgroundTransparency = 1
    scroll.ScrollBarThickness = 3
    scroll.ScrollBarImageColor3 = COLORS.accent
    scroll.Parent            = container
    local layout = Instance.new("UIListLayout")
    layout.Padding          = UDim.new(0,4)
    layout.Parent           = scroll

    for i, session in ipairs(place.sessions) do
        local row = Instance.new("Frame")
        row.Size             = UDim2.new(1,-6,0,40)
        row.BackgroundColor3 = COLORS.panel
        row.BorderSizePixel  = 0
        row.Parent           = scroll
        local rc = Instance.new("UICorner")
        rc.CornerRadius = UDim.new(0,6)
        rc.Parent = row

        local starIcon = session.starred and "⭐" or "☆"
        local lbl = makeLabel(row,
            string.format("%s Session %d — %d actions", starIcon, i, #(session.actions or {})),
            UDim2.new(0.7,0,0,18), UDim2.new(0,6,0,4), 10)
        makeLabel(row,
            "Priority: " .. (session.priority or 1),
            UDim2.new(0.7,0,0,14), UDim2.new(0,6,0,22), 9, COLORS.textDim)

        -- Star button
        local starBtn = makeButton(row, starIcon, UDim2.new(0,28,0,28), UDim2.new(1,-64,0,6), COLORS.panel)
        starBtn.MouseButton1Click:Connect(function()
            session.starred = not session.starred
            starBtn.Text    = session.starred and "⭐" or "☆"
            lbl.Text = string.format("%s Session %d — %d actions",
                session.starred and "⭐" or "☆", i, #(session.actions or {}))
            saveBrain()
        end)

        -- Delete button
        local delBtn = makeButton(row, "🗑", UDim2.new(0,28,0,28), UDim2.new(1,-32,0,6), COLORS.danger)
        delBtn.MouseButton1Click:Connect(function()
            table.remove(place.sessions, i)
            saveBrain()
            row:Destroy()
            VoidGUI.notify("Session " .. i .. " deleted")
            Observer.updateConfidence(place)
        end)

        -- Priority +/-
        local prioUp = makeButton(row, "+", UDim2.new(0,18,0,18), UDim2.new(1,-96,0,6), COLORS.success)
        local prioDn = makeButton(row, "-", UDim2.new(0,18,0,18), UDim2.new(1,-96,0,24), COLORS.danger)
        prioUp.TextColor3 = Color3.fromRGB(20,20,30)
        prioUp.MouseButton1Click:Connect(function()
            session.priority = math.min(5, (session.priority or 1) + 1)
            saveBrain()
        end)
        prioDn.MouseButton1Click:Connect(function()
            session.priority = math.max(1, (session.priority or 1) - 1)
            saveBrain()
        end)
    end

    scroll.CanvasSize = UDim2.new(0,0,0,#place.sessions * 44)
end

function VoidGUI.buildSettingsTab(container)
    local y = 0
    makeLabel(container, "⚙ Settings",
        UDim2.new(1,-12,0,18), UDim2.new(0,6,0,y), 14, COLORS.accent).Font = Enum.Font.GothamBold
    y += 24

    -- Persona
    makeLabel(container, "Persona", UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 11, COLORS.textDim)
    y += 16
    local personas = {"Mirror","Aggressive","Passive"}
    for _, pname in ipairs(personas) do
        local isActive = Config.activePersona == pname
        local pbtn = makeButton(container, pname,
            UDim2.new(1/3,-5,0,24),
            UDim2.new((table.find(personas,pname)-1)/3, 3, 0, y),
            isActive and COLORS.accent or COLORS.panel)
        pbtn.MouseButton1Click:Connect(function()
            Config.activePersona  = pname
            AIState.persona       = pname
            saveConfig()
            VoidGUI.notify("Persona: " .. pname)
        end)
    end
    y += 30

    -- LLM Provider
    makeLabel(container, "LLM Provider", UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 11, COLORS.textDim)
    y += 16
    local providers = {"claude","chatgpt","gemini"}
    for _, prov in ipairs(providers) do
        local isActive = Config.activeProvider == prov
        local pvbtn = makeButton(container, prov,
            UDim2.new(1/3,-5,0,24),
            UDim2.new((table.find(providers,prov)-1)/3, 3, 0, y),
            isActive and COLORS.accent or COLORS.panel)
        pvbtn.MouseButton1Click:Connect(function()
            Config.activeProvider = prov
            saveConfig()
            VoidGUI.notify("Provider: " .. prov)
        end)
    end
    y += 30

    -- API Key input
    makeLabel(container, "API Key (active provider)",
        UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 11, COLORS.textDim)
    y += 16
    local keyBox = Instance.new("TextBox")
    keyBox.Size             = UDim2.new(1,-12,0,28)
    keyBox.Position         = UDim2.new(0,6,0,y)
    keyBox.BackgroundColor3 = COLORS.panel
    keyBox.TextColor3       = COLORS.text
    keyBox.PlaceholderText  = "Enter API key..."
    keyBox.PlaceholderColor3= COLORS.textDim
    keyBox.Text             = Config.apiKeys[Config.activeProvider] or ""
    keyBox.Font             = Enum.Font.Code
    keyBox.TextSize         = 10
    keyBox.ClearTextOnFocus = false
    keyBox.BorderSizePixel  = 0
    keyBox.TextXAlignment   = Enum.TextXAlignment.Left
    keyBox.TextEditable     = true
    keyBox.Parent           = container
    local kbc = Instance.new("UICorner"); kbc.CornerRadius = UDim.new(0,6); kbc.Parent = keyBox
    local kbp = Instance.new("UIPadding"); kbp.PaddingLeft = UDim.new(0,6); kbp.Parent = keyBox
    keyBox.FocusLost:Connect(function()
        Config.apiKeys[Config.activeProvider] = keyBox.Text
        saveConfig()
        VoidGUI.notify("API key saved")
    end)
    y += 34

    -- Keybinds (PC only)
    if isPC then
        makeLabel(container, "Keybinds (PC)",
            UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 11, COLORS.textDim)
        y += 16
        local bindNames = {
            {key="reclaim",   label="Reclaim"},
            {key="toggleObs", label="Observe"},
            {key="toggleLend",label="Lend"},
            {key="toggleGUI", label="GUI"},
        }
        for _, b in ipairs(bindNames) do
            makeLabel(container, b.label,
                UDim2.new(0.4,0,0,22), UDim2.new(0,6,0,y), 10)
            local bindBox = Instance.new("TextBox")
            bindBox.Size             = UDim2.new(0.3,-3,0,22)
            bindBox.Position         = UDim2.new(0.4,3,0,y)
            bindBox.BackgroundColor3 = COLORS.panel
            bindBox.TextColor3       = COLORS.text
            bindBox.Text             = Config.keybinds and Config.keybinds[b.key] or "F"
            bindBox.Font             = Enum.Font.GothamBold
            bindBox.TextSize         = 11
            bindBox.BorderSizePixel  = 0
            bindBox.Parent           = container
            local bbc = Instance.new("UICorner"); bbc.CornerRadius = UDim.new(0,4); bbc.Parent = bindBox
            bindBox.FocusLost:Connect(function()
                local k = bindBox.Text:upper()
                Keybinds.setKey(b.key, k)
                bindBox.Text = k
            end)
            y += 26
        end
    end

    -- Stealth toggle
    local stealthBtn = makeButton(container,
        Config.stealthMode and "🔇 Stealth: ON" or "🔈 Stealth: OFF",
        UDim2.new(1,-12,0,28), UDim2.new(0,6,0,y),
        Config.stealthMode and COLORS.accent or COLORS.panel)
    stealthBtn.MouseButton1Click:Connect(function()
        Config.stealthMode    = not Config.stealthMode
        AIState.isStealth     = Config.stealthMode
        stealthBtn.Text       = Config.stealthMode and "🔇 Stealth: ON" or "🔈 Stealth: OFF"
        stealthBtn.BackgroundColor3 = Config.stealthMode and COLORS.accent or COLORS.panel
        saveConfig()
        if Config.stealthMode then VoidGUI.minimize() end
    end)
    y += 34

    -- Schedule
    makeLabel(container, "Schedule (minutes)",
        UDim2.new(1,-12,0,14), UDim2.new(0,6,0,y), 11, COLORS.textDim)
    y += 16
    local schedBtn = makeButton(container,
        Config.scheduleEnabled and "⏱ Schedule: ON" or "⏱ Schedule: OFF",
        UDim2.new(1,-12,0,28), UDim2.new(0,6,0,y),
        Config.scheduleEnabled and COLORS.success or COLORS.panel)
    schedBtn.MouseButton1Click:Connect(function()
        Config.scheduleEnabled = not Config.scheduleEnabled
        schedBtn.Text = Config.scheduleEnabled and "⏱ Schedule: ON" or "⏱ Schedule: OFF"
        schedBtn.BackgroundColor3 = Config.scheduleEnabled and COLORS.success or COLORS.panel
        saveConfig()
    end)
end

function VoidGUI.build()
    -- Main ScreenGui
    screenGui = Instance.new("ScreenGui")
    screenGui.Name             = "VoidAIGui"
    screenGui.ResetOnSpawn     = false
    screenGui.ZIndexBehavior   = Enum.ZIndexBehavior.Sibling
    screenGui.IgnoreGuiInset   = true
    screenGui.Parent           = PlayerGui

    -- ---- MINI BUTTON (shown when minimized) ----
    miniButton = Instance.new("TextButton")
    miniButton.Size             = UDim2.new(0, isMobile and 52 or 44, 0, isMobile and 52 or 44)
    miniButton.Position         = UDim2.new(0, 10, 0.4, 0)
    miniButton.BackgroundColor3 = COLORS.accent
    miniButton.TextColor3       = COLORS.text
    miniButton.Text             = "🌙"
    miniButton.Font             = Enum.Font.GothamBold
    miniButton.TextSize         = isMobile and 22 or 20
    miniButton.BorderSizePixel  = 0
    miniButton.Visible          = false
    miniButton.ZIndex           = 50
    miniButton.Parent           = screenGui
    local mc = Instance.new("UICorner")
    mc.CornerRadius = UDim.new(1,0)
    mc.Parent       = miniButton
    makeDraggable(miniButton)
    miniButton.MouseButton1Click:Connect(function()
        VoidGUI.maximize()
    end)

    -- ---- MAIN PANEL ----
    local panelW = isMobile and 420 or 480
    local panelH = isMobile and 280 or 320
    mainFrame = Instance.new("Frame")
    mainFrame.Name              = "VoidAIPanel"
    mainFrame.Size              = UDim2.new(0, panelW, 0, panelH)
    mainFrame.Position          = UDim2.new(0, 10, 0.1, 0)
    mainFrame.BackgroundColor3  = COLORS.bg
    mainFrame.BorderSizePixel   = 0
    mainFrame.ClipsDescendants  = true
    mainFrame.Parent            = screenGui
    local mc2 = Instance.new("UICorner")
    mc2.CornerRadius = UDim.new(0,10)
    mc2.Parent = mainFrame

    -- Border glow
    local stroke = Instance.new("UIStroke")
    stroke.Color     = COLORS.accent
    stroke.Thickness = 1
    stroke.Transparency = 0.5
    stroke.Parent    = mainFrame

    -- Title bar
    local titleBar = Instance.new("Frame")
    titleBar.Size              = UDim2.new(1,0,0,32)
    titleBar.BackgroundColor3  = COLORS.panel
    titleBar.BorderSizePixel   = 0
    titleBar.Parent            = mainFrame
    local tbc = Instance.new("UICorner"); tbc.CornerRadius = UDim.new(0,10); tbc.Parent = titleBar

    makeLabel(titleBar, "  🤖 VoidAI",
        UDim2.new(0.7,0,1,0), UDim2.new(0,0,0,0), 13, COLORS.text).Font = Enum.Font.GothamBold

    local closeBtn = Instance.new("TextButton")
    closeBtn.Size             = UDim2.new(0,28,0,28)
    closeBtn.Position         = UDim2.new(1,-32,0,2)
    closeBtn.BackgroundColor3 = COLORS.danger
    closeBtn.Text             = "×"
    closeBtn.Font             = Enum.Font.GothamBold
    closeBtn.TextSize         = 16
    closeBtn.TextColor3       = COLORS.text
    closeBtn.BorderSizePixel  = 0
    closeBtn.Parent           = titleBar
    local cc = Instance.new("UICorner"); cc.CornerRadius = UDim.new(0,6); cc.Parent = closeBtn
    closeBtn.MouseButton1Click:Connect(function()
        VoidGUI.minimize()
    end)

    makeDraggable(mainFrame, titleBar)

    -- ---- RESIZE HANDLE (bottom-right corner) ----
    local resizeHandle = Instance.new("TextButton")
    resizeHandle.Size             = UDim2.new(0, 16, 0, 16)
    resizeHandle.Position         = UDim2.new(1, -16, 1, -16)
    resizeHandle.BackgroundColor3 = COLORS.accent
    resizeHandle.Text             = ""
    resizeHandle.BorderSizePixel  = 0
    resizeHandle.ZIndex           = 20
    resizeHandle.Parent           = mainFrame
    local rhc = Instance.new("UICorner"); rhc.CornerRadius = UDim.new(0,3); rhc.Parent = resizeHandle
    -- small diagonal lines icon
    local rhLbl = Instance.new("TextLabel")
    rhLbl.Size = UDim2.new(1,0,1,0); rhLbl.BackgroundTransparency=1
    rhLbl.Text = "⤡"; rhLbl.TextSize=10; rhLbl.TextColor3=COLORS.text
    rhLbl.Font=Enum.Font.GothamBold; rhLbl.Parent=resizeHandle

    do
        local resizing, resizeStart, startSize = false, nil, nil
        resizeHandle.InputBegan:Connect(function(input)
            if input.UserInputType == Enum.UserInputType.MouseButton1 or
               input.UserInputType == Enum.UserInputType.Touch then
                resizing    = true
                resizeStart = input.Position
                startSize   = mainFrame.AbsoluteSize
            end
        end)
        UserInputService.InputChanged:Connect(function(input)
            if not resizing then return end
            if input.UserInputType == Enum.UserInputType.MouseMovement or
               input.UserInputType == Enum.UserInputType.Touch then
                local delta = input.Position - resizeStart
                local newW  = math.clamp(startSize.X + delta.X, 320, 700)
                local newH  = math.clamp(startSize.Y + delta.Y, 220, 600)
                mainFrame.Size = UDim2.new(0, newW, 0, newH)
            end
        end)
        UserInputService.InputEnded:Connect(function(input)
            if input.UserInputType == Enum.UserInputType.MouseButton1 or
               input.UserInputType == Enum.UserInputType.Touch then
                resizing = false
            end
        end)
    end

    -- Tab bar
    local tabBar = Instance.new("Frame")
    tabBar.Size             = UDim2.new(1,0,0,28)
    tabBar.Position         = UDim2.new(0,0,0,32)
    tabBar.BackgroundColor3 = COLORS.panel
    tabBar.BorderSizePixel  = 0
    tabBar.Parent           = mainFrame

    local tabs = {
        {id="main",     label="Main"},
        {id="stats",    label="Stats"},
        {id="replay",   label="Replay"},
        {id="settings", label="Setup"},
    }

    local tabContents = {}
    for i, tab in ipairs(tabs) do
        local tbtn = Instance.new("TextButton")
        tbtn.Size             = UDim2.new(1/#tabs,0,1,0)
        tbtn.Position         = UDim2.new((i-1)/#tabs,0,0,0)
        tbtn.BackgroundTransparency = 1
        tbtn.Text             = tab.label
        tbtn.Font             = Enum.Font.GothamBold
        tbtn.TextSize         = 10
        tbtn.TextColor3       = COLORS.textDim
        tbtn.Parent           = tabBar

        -- Content frame
        local content = Instance.new("ScrollingFrame")
        content.Size              = UDim2.new(1,0,1,0)
        content.Position          = UDim2.new(0,0,0,60)
        content.BackgroundTransparency = 1
        content.ScrollBarThickness = 3
        content.ScrollBarImageColor3 = COLORS.accent
        content.CanvasSize        = UDim2.new(0,0,0,500)
        content.Visible           = (tab.id == "main")
        content.Parent            = mainFrame
        tabContents[tab.id]       = content

        tbtn.MouseButton1Click:Connect(function()
            VoidGUI.tab = tab.id
            for _, tc in pairs(tabContents) do tc.Visible = false end
            content.Visible = true
            for _, child in ipairs(tabBar:GetChildren()) do
                if child:IsA("TextButton") then
                    child.TextColor3 = COLORS.textDim
                end
            end
            tbtn.TextColor3 = COLORS.accent
        end)

        -- Active underline
        if tab.id == "main" then
            tbtn.TextColor3 = COLORS.accent
        end
    end

    -- Build tab contents
    VoidGUI.buildMainTab(tabContents["main"])
    VoidGUI.buildStatsTab(tabContents["stats"])
    VoidGUI.buildReplayTab(tabContents["replay"])
    VoidGUI.buildSettingsTab(tabContents["settings"])

    -- Mobile floating action buttons (reclaim etc)
    if isMobile then
        local reclaimBtn = Instance.new("TextButton")
        reclaimBtn.Size             = UDim2.new(0,60,0,60)
        reclaimBtn.Position         = UDim2.new(1,-70,0.8,0)
        reclaimBtn.BackgroundColor3 = COLORS.danger
        reclaimBtn.Text             = "✋"
        reclaimBtn.Font             = Enum.Font.GothamBold
        reclaimBtn.TextSize         = 26
        reclaimBtn.TextColor3       = COLORS.text
        reclaimBtn.BorderSizePixel  = 0
        reclaimBtn.Visible          = false
        reclaimBtn.ZIndex           = 60
        reclaimBtn.Parent           = screenGui
        local rc3 = Instance.new("UICorner"); rc3.CornerRadius = UDim.new(1,0); rc3.Parent = reclaimBtn
        makeDraggable(reclaimBtn)
        reclaimBtn.MouseButton1Click:Connect(function()
            GhostController.reclaim()
            VoidGUI.updateDisplay()
        end)

        -- Show reclaim button only when AI is in control
        RunService.Heartbeat:Connect(function()
            reclaimBtn.Visible = (AIState.mode == "lend" or AIState.mode == "ghost" or AIState.mode == "llm")
        end)
    end
end

-- ============================================================
-- SECTION 20: THREAT HUD
-- ============================================================
local ThreatHUD = {}

local function buildThreatHUD()
    if AIState.isStealth or not screenGui then return end

    local indicator = Instance.new("Frame")
    indicator.Name              = "ThreatIndicator"
    indicator.Size              = UDim2.new(0, 190, 0, 30)
    indicator.Position          = UDim2.new(0.5, -95, 0, 8)
    indicator.BackgroundColor3  = Color3.fromRGB(180, 30, 30)
    indicator.BorderSizePixel   = 0
    indicator.Visible           = false
    indicator.ZIndex            = 80
    indicator.Parent            = screenGui
    local ic = Instance.new("UICorner")
    ic.CornerRadius = UDim.new(0, 8)
    ic.Parent       = indicator

    local lbl = Instance.new("TextLabel")
    lbl.Size               = UDim2.new(1, 0, 1, 0)
    lbl.BackgroundTransparency = 1
    lbl.TextColor3         = Color3.fromRGB(255, 255, 255)
    lbl.Font               = Enum.Font.GothamBold
    lbl.TextSize           = 13
    lbl.Text               = "🚨 THREAT DETECTED"
    lbl.Parent             = indicator

    ThreatHUD.frame = indicator
    ThreatHUD.label = lbl

    -- Pulse
    task.spawn(function()
        while indicator and indicator.Parent do
            TweenService:Create(indicator, TweenInfo.new(0.3),
                {BackgroundTransparency = 0.5}):Play()
            task.wait(0.3)
            TweenService:Create(indicator, TweenInfo.new(0.3),
                {BackgroundTransparency = 0}):Play()
            task.wait(0.3)
        end
    end)

    RunService.Heartbeat:Connect(function()
        if indicator and indicator.Parent then
            indicator.Visible = AIState.threatDetected and not AIState.isStealth
        end
    end)
end

-- ============================================================
-- SECTION 21: SEQUENCE DETECTOR
-- ============================================================
local SequenceDetector = {}
SequenceDetector.windowSize    = 5
SequenceDetector.knownPatterns = {}

function SequenceDetector.analyze()
    local place = getCurrentPlace()
    if not place or #place.sessions < 2 then return end

    local seqMap = {}
    for _, session in ipairs(place.sessions) do
        local actions = session.actions or {}
        for i = 1, #actions - SequenceDetector.windowSize do
            local key = ""
            for j = i, i + SequenceDetector.windowSize - 1 do
                key = key .. (actions[j].type or "?") .. "|"
            end
            seqMap[key] = (seqMap[key] or 0) + 1
        end
    end

    SequenceDetector.knownPatterns = {}
    for seq, count in pairs(seqMap) do
        if count >= 3 then
            table.insert(SequenceDetector.knownPatterns, {seq = seq, count = count})
        end
    end
    table.sort(SequenceDetector.knownPatterns, function(a, b) return a.count > b.count end)
end

function SequenceDetector.matchCurrent(recentActions)
    if #SequenceDetector.knownPatterns == 0 then return nil end
    local key = ""
    local start = math.max(1, #recentActions - SequenceDetector.windowSize + 1)
    for i = start, #recentActions do
        key = key .. (recentActions[i].type or "?") .. "|"
    end
    for _, pat in ipairs(SequenceDetector.knownPatterns) do
        if pat.seq == key then return pat end
    end
    return nil
end

task.spawn(function()
    while true do
        task.wait(60)
        SequenceDetector.analyze()
    end
end)

-- ============================================================
-- SECTION 22: SMART REPLAY (pattern-aware)
-- ============================================================
local _originalStepReplay = GhostController.stepReplay

function GhostController.stepReplay()
    if not GhostController.replaySession then return end
    local actions = GhostController.replaySession.actions
    if not actions or #actions == 0 then return end

    -- Pattern-based decision
    local place = getCurrentPlace()
    if place and #SequenceDetector.knownPatterns > 0 then
        local match = SequenceDetector.matchCurrent(actions)
        if match then
            for _, session in ipairs(place.sessions) do
                local sactions = session.actions or {}
                for i = 1, #sactions - SequenceDetector.windowSize do
                    local key = ""
                    for j = i, i + SequenceDetector.windowSize - 1 do
                        key = key .. (sactions[j].type or "?") .. "|"
                    end
                    if key == match.seq and sactions[i + SequenceDetector.windowSize] then
                        local nextAction = sactions[i + SequenceDetector.windowSize]
                        if nextAction.type == "move" then
                            GhostController.executeMove(nextAction.data)
                        elseif nextAction.type == "ui_click" then
                            GhostController.executeUIClick(nextAction.data)
                        elseif nextAction.type == "jump" then
                            local char = LocalPlayer.Character
                            local hum = char and char:FindFirstChildOfClass("Humanoid")
                            if hum then hum.Jump = true end
                        end
                        return
                    end
                end
            end
        end
    end

    -- Linear fallback
    if GhostController.replayIndex > #actions then
        GhostController.replayIndex = 1
        return
    end

    local action = actions[GhostController.replayIndex]
    GhostController.replayIndex += 1

    local char = LocalPlayer.Character
    local hum  = char and char:FindFirstChildOfClass("Humanoid")

    if action.type == "move" then
        GhostController.executeMove(action.data)
    elseif action.type == "ui_click" then
        GhostController.executeUIClick(action.data)
    elseif action.type == "jump" then
        if hum then hum.Jump = true end
    elseif action.type == "sprint_start" then
        if hum then hum.WalkSpeed = AIState.persona == "Passive" and 14 or 24 end
    elseif action.type == "sprint_end" then
        if hum then hum.WalkSpeed = AIState.persona == "Aggressive" and 20 or 16 end
    end
end

-- ============================================================
-- SECTION 23: JUMP + SPRINT TRACKING (Event-driven)
-- ============================================================
if not isMobile then
    UserInputService.InputBegan:Connect(function(input, processed)
        if processed then return end
        if not AIState.isLearning or not AIState.currentSession then return end
        if input.KeyCode == Enum.KeyCode.Space then
            table.insert(AIState.currentSession.actions, {
                type = "jump",
                data = {t = os.time() - AIState.sessionStart}
            })
        elseif input.KeyCode == Enum.KeyCode.LeftShift then
            table.insert(AIState.currentSession.actions, {
                type = "sprint_start",
                data = {t = os.time() - AIState.sessionStart}
            })
        end
    end)

    UserInputService.InputEnded:Connect(function(input, processed)
        if not AIState.isLearning or not AIState.currentSession then return end
        if input.KeyCode == Enum.KeyCode.LeftShift then
            table.insert(AIState.currentSession.actions, {
                type = "sprint_end",
                data = {t = os.time() - AIState.sessionStart}
            })
        end
    end)
end

-- Re-hook on respawn
LocalPlayer.CharacterAdded:Connect(function(char)
    task.wait(1)
    local hum = char:WaitForChild("Humanoid", 5)
    if hum then
        if isMobile then
            hum.Jumping:Connect(function(active)
                if active and AIState.isLearning and AIState.currentSession then
                    table.insert(AIState.currentSession.actions, {
                        type = "jump",
                        data = {t = os.time() - AIState.sessionStart}
                    })
                end
            end)
        end
        -- Reset position tracking
        local hrp = char:WaitForChild("HumanoidRootPart", 5)
        if hrp then Observer.lastPosition = hrp.Position end
    end
    -- Auto-pause AI control during respawn
    if AIState.mode == "lend" or AIState.mode == "ghost" then
        AIState.isPaused = true
        task.wait(2)
        AIState.isPaused = false
    end
end)

-- ============================================================
-- SECTION 24: OTHER PLAYER OBSERVER (low priority)
-- ============================================================
RunService.Heartbeat:Connect(function()
    if AIState.tickCounter % 5 ~= 0 then return end
    if not AIState.isLearning then return end

    for _, p in ipairs(Players:GetPlayers()) do
        if p ~= LocalPlayer and p.Character then
            local hum = p.Character:FindFirstChildOfClass("Humanoid")
            local hrp = p.Character:FindFirstChild("HumanoidRootPart")
            if hum and hrp and hum.WalkSpeed > 20 then
                -- Multiple players fleeing = potential threat signal
                -- Already handled in ThreatDetector.scan()
            end
        end
    end
end)

-- ============================================================
-- SECTION 25: CONFIDENCE COLOR CODING
-- ============================================================
local function getConfidenceColor(value)
    if value >= 70 then return COLORS.success
    elseif value >= 40 then return COLORS.warning
    else return COLORS.danger end
end

local _origUpdateDisplay = VoidGUI.updateDisplay
function VoidGUI.updateDisplay()
    _origUpdateDisplay()
    local place = getCurrentPlace()
    if place then
        if barMove then barMove.BackgroundColor3 = getConfidenceColor(place.confidence.movement) end
        if barUI   then barUI.BackgroundColor3   = getConfidenceColor(place.confidence.ui)       end
        if barSeq  then barSeq.BackgroundColor3  = getConfidenceColor(place.confidence.sequences) end
    end
end

-- ============================================================
-- SECTION 26: LOW CONFIDENCE WARNING
-- ============================================================
local _origStartGhost = GhostController.startGhost
function GhostController.startGhost()
    local place = getCurrentPlace()
    if place then
        local avg = (place.confidence.movement + place.confidence.ui + place.confidence.sequences) / 3
        if avg < 20 then
            VoidGUI.notify("⚠ Very low confidence (" .. math.floor(avg) .. "%). Train more!")
            task.wait(1.5)
        elseif avg < 40 then
            VoidGUI.notify("⚠ Low confidence (" .. math.floor(avg) .. "%). AI may be rough.")
        end
    end
    return _origStartGhost()
end

-- ============================================================
-- SECTION 27: CHAT COMMANDS
-- ============================================================
local CHAT_PREFIX = "/void"

local CHAT_COMMANDS = {
    learn   = function() AIState.isLearning = true;  Observer.startSession(); VoidGUI.notify("Learning started") end,
    stop    = function() AIState.isLearning = false; Observer.endSession();   VoidGUI.notify("Learning stopped") end,
    ghost   = function() GhostController.startGhost() end,
    lend    = function() GhostController.startLend()  end,
    reclaim = function() GhostController.reclaim()    end,
    pause   = function()
        AIState.isPaused = not AIState.isPaused
        VoidGUI.notify(AIState.isPaused and "Paused" or "Resumed")
    end,
    stealth = function()
        Config.stealthMode = not Config.stealthMode
        AIState.isStealth  = Config.stealthMode
        saveConfig()
        if Config.stealthMode then VoidGUI.minimize() else VoidGUI.maximize() end
    end,
    safe    = function() Observer.tagCurrentZone("safe");       VoidGUI.notify("Zone: SAFE")   end,
    danger  = function() Observer.tagCurrentZone("danger");     VoidGUI.notify("Zone: DANGER") end,
    exit    = function() Observer.tagCurrentZone("exit");       VoidGUI.notify("Zone: EXIT")   end,
    item    = function() Observer.tagCurrentZone("item_spawn"); VoidGUI.notify("Zone: ITEM")   end,
    save    = function() saveBrain(); VoidGUI.notify("Brain saved!") end,
    llm     = function()
        local canUse, reason = LLMController.canUseLLM()
        if canUse then AIState.mode = "llm"; VoidGUI.notify("LLM mode ON")
        else VoidGUI.notify("LLM blocked: " .. (reason or "?")) end
    end,
    status  = function()
        local place = getCurrentPlace()
        VoidGUI.notify(string.format("Mode:%s Sess:%d Move:%d%% UI:%d%%",
            AIState.mode,
            place and #place.sessions or 0,
            place and place.confidence.movement or 0,
            place and place.confidence.ui or 0))
    end,
    help = function()
        VoidGUI.notify("Cmds: learn stop ghost lend reclaim pause stealth safe danger exit item save llm status")
    end,
}

pcall(function()
    LocalPlayer.Chatted:Connect(function(msg)
        local parts = msg:lower():split(" ")
        if parts[1] ~= CHAT_PREFIX then return end
        local cmd = parts[2]
        if cmd and CHAT_COMMANDS[cmd] then
            CHAT_COMMANDS[cmd]()
            VoidGUI.updateDisplay()
        end
    end)
end)

-- ============================================================
-- SECTION 28: MENTAL MAP VISUALIZER
-- ============================================================
local function buildMentalMapView(parent, yPos)
    local place = getCurrentPlace()
    if not place then return end

    local TAG_COLORS = {
        visited    = Color3.fromRGB(50,  50,  90),
        safe       = Color3.fromRGB(87,  242, 135),
        danger     = Color3.fromRGB(237, 66,  69),
        item_spawn = Color3.fromRGB(254, 231, 92),
        exit       = Color3.fromRGB(88,  101, 242),
    }

    local mapFrame = Instance.new("Frame")
    mapFrame.Size              = UDim2.new(1, -12, 0, 110)
    mapFrame.Position          = UDim2.new(0, 6, 0, yPos)
    mapFrame.BackgroundColor3  = Color3.fromRGB(8, 8, 14)
    mapFrame.BorderSizePixel   = 0
    mapFrame.ClipsDescendants  = true
    mapFrame.Parent            = parent
    local mc = Instance.new("UICorner"); mc.CornerRadius = UDim.new(0,6); mc.Parent = mapFrame
    makeLabel(mapFrame, "🗺 Mental Map", UDim2.new(1,0,0,14), UDim2.new(0,4,0,2), 9, COLORS.textDim)

    local landmarks = place.mentalMap.landmarks or {}
    local minX, maxX, minZ, maxZ = math.huge, -math.huge, math.huge, -math.huge
    for _, lm in pairs(landmarks) do
        if lm.pos.x < minX then minX = lm.pos.x end
        if lm.pos.x > maxX then maxX = lm.pos.x end
        if lm.pos.z < minZ then minZ = lm.pos.z end
        if lm.pos.z > maxZ then maxZ = lm.pos.z end
    end
    local rangeX = math.max(1, maxX - minX)
    local rangeZ = math.max(1, maxZ - minZ)

    local dotCount = 0
    for _, lm in pairs(landmarks) do
        if dotCount >= 150 then break end
        dotCount += 1
        local nx = (lm.pos.x - minX) / rangeX
        local nz = (lm.pos.z - minZ) / rangeZ
        local dot = Instance.new("Frame")
        dot.Size             = UDim2.new(0, 4, 0, 4)
        dot.Position         = UDim2.new(nx * 0.9 + 0.05, -2, nz * 0.7 + 0.15, -2)
        dot.BackgroundColor3 = TAG_COLORS[lm.tag] or TAG_COLORS.visited
        dot.BorderSizePixel  = 0
        dot.Parent           = mapFrame
        local dc = Instance.new("UICorner"); dc.CornerRadius = UDim.new(1,0); dc.Parent = dot
    end

    -- Player position dot
    local char = LocalPlayer.Character
    if char then
        local hrp = char:FindFirstChild("HumanoidRootPart")
        if hrp then
            local nx = rangeX > 1 and (hrp.Position.X - minX) / rangeX or 0.5
            local nz = rangeZ > 1 and (hrp.Position.Z - minZ) / rangeZ or 0.5
            local pd = Instance.new("Frame")
            pd.Size             = UDim2.new(0, 7, 0, 7)
            pd.Position         = UDim2.new(math.clamp(nx*0.9+0.05,0,1), -3, math.clamp(nz*0.7+0.15,0,1), -3)
            pd.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
            pd.BorderSizePixel  = 0
            pd.ZIndex           = 5
            pd.Parent           = mapFrame
            local pdc = Instance.new("UICorner"); pdc.CornerRadius = UDim.new(1,0); pdc.Parent = pd
        end
    end

    -- Legend row
    local legendData = {{"safe","Safe"},{"danger","Danger"},{"exit","Exit"},{"item_spawn","Item"}}
    local lx = 4
    for _, li in ipairs(legendData) do
        local ldot = Instance.new("Frame")
        ldot.Size = UDim2.new(0,5,0,5); ldot.Position = UDim2.new(0,lx,1,-14)
        ldot.BackgroundColor3 = TAG_COLORS[li[1]]; ldot.BorderSizePixel = 0; ldot.Parent = mapFrame
        local lc = Instance.new("UICorner"); lc.CornerRadius = UDim.new(1,0); lc.Parent = ldot
        makeLabel(mapFrame, li[2], UDim2.new(0,28,0,10), UDim2.new(0,lx+7,1,-16), 8, COLORS.textDim)
        lx += 40
    end
end

-- ============================================================
-- SECTION 29: BRAIN EXPORT
-- ============================================================
local function exportBrainSummary()
    local place = getCurrentPlace()
    if not place then VoidGUI.notify("No brain loaded"); return end

    local lines = {
        "=== VoidAI Brain Export ===",
        "Universe: " .. getUniverseId(),
        "Place: "    .. getPlaceId() .. " (" .. (place.placeName or "?") .. ")",
        "Sessions: " .. #place.sessions,
        "Total Actions: " .. (place.totalActions or 0),
        string.format("Confidence - Move:%d%% UI:%d%% Seq:%d%%",
            place.confidence.movement, place.confidence.ui, place.confidence.sequences),
        "",
        "--- Top UI Buttons ---",
    }
    local sorted = {}
    for name, count in pairs(place.topUIButtons or {}) do
        table.insert(sorted, name .. ": " .. count .. "x")
    end
    table.sort(sorted)
    for _, s in ipairs(sorted) do table.insert(lines, "  " .. s) end

    table.insert(lines, "")
    table.insert(lines, "--- Mental Map Landmarks ---")
    local lmCount = 0
    for _, lm in pairs(place.mentalMap.landmarks or {}) do
        lmCount += 1
        table.insert(lines, string.format("  [%s] (%.0f,%.0f,%.0f) visits:%d",
            lm.tag, lm.pos.x, lm.pos.y, lm.pos.z, lm.visits))
    end
    if lmCount == 0 then table.insert(lines, "  (none yet)") end

    table.insert(lines, "")
    table.insert(lines, "--- Known Patterns ---")
    if #SequenceDetector.knownPatterns > 0 then
        for i, pat in ipairs(SequenceDetector.knownPatterns) do
            if i > 10 then break end
            table.insert(lines, string.format("  Pattern %d (x%d): %s", i, pat.count, pat.seq))
        end
    else
        table.insert(lines, "  (none — train 2+ sessions first)")
    end

    table.insert(lines, "")
    table.insert(lines, "Exported: " .. os.date and os.date("%Y-%m-%d %H:%M:%S") or tostring(os.time()))

    local path = BRAIN_FOLDER .. "/export_" .. getPlaceId() .. ".txt"
    Exec.writeFile(path, table.concat(lines, "\n"))
    VoidGUI.notify("Exported to " .. path)
end

-- ============================================================
-- SECTION 30: HORROR GAME AUTO-DETECT
-- ============================================================
local HORROR_KEYWORDS = {
    "doors","horror","scary","haunted","dark","nightmare",
    "paranormal","escape","survival","entity","monster","backrooms",
}

local function detectAndConfigureHorror()
    local place = getCurrentPlace()
    if not place then return end
    local gname = (place.placeName or ""):lower()
    local isHorror = false
    for _, kw in ipairs(HORROR_KEYWORDS) do
        if gname:find(kw) then isHorror = true; break end
    end
    if not isHorror then return end

    VoidGUI.notify("🚨 Horror game detected — threat detection enhanced")

    -- Pre-tag safe objects in workspace
    for _, obj in ipairs(workspace:GetDescendants()) do
        if obj:IsA("BasePart") then
            for _, safe in ipairs(BASE_KNOWLEDGE.horrorDefaults.safeObjects) do
                if obj.Name:lower():find(safe:lower()) then
                    local posKey = math.floor(obj.Position.X/10) .. "_" .. math.floor(obj.Position.Z/10)
                    if not place.mentalMap.landmarks[posKey] then
                        place.mentalMap.landmarks[posKey] = {
                            pos   = {x=obj.Position.X, y=obj.Position.Y, z=obj.Position.Z},
                            tag   = "safe",
                            visits= 1,
                            note  = "auto:" .. obj.Name,
                        }
                    end
                end
            end
        end
    end
end

-- ============================================================
-- SECTION 31: SCHEDULE COUNTDOWN
-- ============================================================
task.spawn(function()
    while true do
        task.wait(10)
        if Config.scheduleEnabled and not AIState.scheduleActive then
            local startSec   = (Config.scheduleStartMin or 0) * 60
            local remaining  = startSec - AIState.scheduleTimer
            if remaining > 0 and remaining < 120 then
                VoidGUI.notify(string.format("⏱ AI starts in %.0f sec", remaining))
            end
        end
    end
end)

-- ============================================================
-- SECTION 32: SAFETY HOOKS
-- ============================================================
Players.LocalPlayer.AncestryChanged:Connect(function()
    if not Players.LocalPlayer.Parent then
        if AIState.isLearning and AIState.currentSession then
            Observer.endSession()
        end
        saveBrain()
        saveConfig()
    end
end)

LocalPlayer.CharacterRemoving:Connect(function()
    if AIState.mode ~= "idle" and AIState.mode ~= "observing" then
        GhostController.reclaim()
        VoidGUI.notify("Character removed — control reclaimed")
    end
end)

-- ============================================================
-- SECTION 19: INITIALIZATION (FINAL)
-- ============================================================
local function init()
    -- Setup folders
    Exec.makeFolder(BRAIN_FOLDER)

    -- Load config first
    loadConfig()

    -- Apply saved config
    if Config.stealthMode then AIState.isStealth = true end
    if Config.activePersona then AIState.persona = Config.activePersona end
    if Config.keybinds then
        for action, keyName in pairs(Config.keybinds) do
            if KEY_MAP[keyName] then
                Keybinds.binds[action] = KEY_MAP[keyName]
            end
        end
    end

    -- Load brain
    loadBrain()

    -- Build GUI
    if not AIState.isStealth then
        VoidGUI.build()
        VoidGUI.updateDisplay()
    else
        -- Stealth: build but hide, mini button only
        VoidGUI.build()
        if mainFrame then mainFrame.Visible = false end
        if miniButton then miniButton.Visible = true end
    end

    -- Build threat HUD after GUI exists
    task.spawn(function()
        task.wait(0.3)
        buildThreatHUD()
    end)

    -- Run horror detection after brain loads
    task.spawn(function()
        task.wait(4)
        detectAndConfigureHorror()
    end)

    -- Run sequence analysis on startup if brain already has sessions
    task.spawn(function()
        task.wait(3)
        SequenceDetector.analyze()
    end)

    -- Add mental map to stats tab after GUI builds
    task.spawn(function()
        task.wait(0.5)
        if screenGui then
            local panel = screenGui:FindFirstChild("VoidAIPanel")
            if panel then
                local scrollFrames = {}
                for _, child in ipairs(panel:GetChildren()) do
                    if child:IsA("ScrollingFrame") then
                        table.insert(scrollFrames, child)
                    end
                end
                -- Stats tab = index 2, Settings tab = index 4
                local statsFrame    = scrollFrames[2]
                local settingsFrame = scrollFrames[4]
                if statsFrame then
                    buildMentalMapView(statsFrame, 330)
                    statsFrame.CanvasSize = UDim2.new(0,0,0,460)
                end
                if settingsFrame then
                    local exportBtn = makeButton(settingsFrame,
                        "📤 Export Brain Summary",
                        UDim2.new(1,-12,0,28),
                        UDim2.new(0,6,0,500),
                        COLORS.panel)
                    exportBtn.MouseButton1Click:Connect(exportBrainSummary)
                    settingsFrame.CanvasSize = UDim2.new(0,0,0,560)
                end
            end
        end
    end)

    -- Live display update loop (every second)
    task.spawn(function()
        while true do
            task.wait(1)
            VoidGUI.updateDisplay()
        end
    end)

    -- Welcome notification
    task.wait(0.5)
    if not AIState.isStealth then
        local platform = isMobile and "Mobile 📱" or "PC 🖥"
        local brainStatus = Exec.isFile(getBrainPath()) and "Brain loaded ✓" or "New brain created"
        VoidGUI.notify("VoidAI v1.0 — " .. platform .. " — " .. brainStatus)
    end

    -- Print to output for executor console
    print("[VoidAI] Loaded successfully")
    print("[VoidAI] Platform: " .. (isMobile and "Mobile" or "PC"))
    print("[VoidAI] Brain path: " .. getBrainPath())
    print("[VoidAI] Universe: " .. getUniverseId() .. " | Place: " .. getPlaceId())
    print("[VoidAI] Chat commands: /void help")
    if isPC then
        print("[VoidAI] Keybinds: G=Learn  H=Lend  J=GUI  F=Reclaim")
    end
end

-- ============================================================
-- SECTION 33: ADAPTIVE THINKING MODE
-- ============================================================
--[[
    Adaptive Thinking Mode (ATM):
    - Activates automatically when player is AFK (no input for N seconds)
    - OR manually toggled from GUI / chat command
    - AI watches ALL other players in the server simultaneously
    - Learns their movement paths, sequences, clustering patterns
    - Cross-references: 3+ players took same path = high-confidence route
    - Tags player clusters as "safe", player flee-zones as "danger"
    - All observed data is weighted at 60% trust vs 100% for self-recorded
    - Stops instantly the moment user touches any input
    - Brain data from ATM is clearly marked so you can see what came from it
]]

local AdaptiveThinking = {}

-- State
AdaptiveThinking.active        = false
AdaptiveThinking.manualOverride= false   -- user forced it on via GUI
AdaptiveThinking.afkTimer      = 0
AdaptiveThinking.afkThreshold  = 30     -- seconds of no input = AFK
AdaptiveThinking.TRUST_WEIGHT  = 0.6    -- observed data trust vs 1.0 for self
AdaptiveThinking.tickAccum     = 0
AdaptiveThinking.sampleRate    = 0.4    -- seconds between player samples (adaptive)
AdaptiveThinking.sessionStart  = 0

-- Per-player tracking table: stores last known positions + action hints
AdaptiveThinking.playerTrackers = {}

-- Observed session accumulator (separate from self-recorded sessions)
AdaptiveThinking.observedSession = nil

-- Path frequency map: posKey -> count across all observed players
AdaptiveThinking.pathFrequency   = {}

-- ---- AFK DETECTION ----

local lastInputTime = os.time()

if not isMobile then
    UserInputService.InputBegan:Connect(function(input, processed)
        -- Any real input = not AFK
        local t = input.UserInputType
        if t == Enum.UserInputType.Keyboard
        or t == Enum.UserInputType.MouseButton1
        or t == Enum.UserInputType.MouseButton2
        or t == Enum.UserInputType.MouseMovement then
            lastInputTime = os.time()
            -- If ATM was running in auto mode, stop it
            if AdaptiveThinking.active and not AdaptiveThinking.manualOverride then
                AdaptiveThinking.stop("Input detected — AFK mode off")
            end
        end
    end)

    UserInputService.InputChanged:Connect(function(input)
        if input.UserInputType == Enum.UserInputType.MouseMovement then
            lastInputTime = os.time()
            if AdaptiveThinking.active and not AdaptiveThinking.manualOverride then
                AdaptiveThinking.stop("Mouse moved — AFK mode off")
            end
        end
    end)
else
    -- Mobile: any touch = not AFK
    UserInputService.TouchStarted:Connect(function()
        lastInputTime = os.time()
        if AdaptiveThinking.active and not AdaptiveThinking.manualOverride then
            AdaptiveThinking.stop("Touch detected — AFK mode off")
        end
    end)
end

-- ---- CORE FUNCTIONS ----

function AdaptiveThinking.start(reason)
    if AdaptiveThinking.active then return end
    AdaptiveThinking.active       = true
    AdaptiveThinking.sessionStart = os.time()
    AdaptiveThinking.pathFrequency= {}
    AdaptiveThinking.playerTrackers = {}

    -- Create a fresh observed session
    AdaptiveThinking.observedSession = {
        id          = os.time(),
        type        = "adaptive_observed",  -- marked as ATM data
        trustWeight = AdaptiveThinking.TRUST_WEIGHT,
        actions     = {},
        movements   = {},
        sequences   = {},
        playerPaths = {},   -- per-player path logs
        startTime   = os.time(),
        endTime     = nil,
        playerCount = #Players:GetPlayers() - 1,  -- exclude self
        source      = "adaptive_thinking",
    }

    VoidGUI.notify("🧠 Adaptive Thinking ON — watching " ..
        tostring(#Players:GetPlayers() - 1) .. " players")
    print("[VoidAI:ATM] Started — reason: " .. (reason or "manual"))
end

function AdaptiveThinking.stop(reason)
    if not AdaptiveThinking.active then return end
    AdaptiveThinking.active        = false
    AdaptiveThinking.manualOverride= false

    -- Finalize and merge observed session into brain
    if AdaptiveThinking.observedSession then
        AdaptiveThinking.observedSession.endTime = os.time()
        AdaptiveThinking.mergeIntoBrain()
    end

    AdaptiveThinking.observedSession = nil
    VoidGUI.notify("🧠 Adaptive Thinking OFF — brain updated")
    print("[VoidAI:ATM] Stopped — reason: " .. (reason or "manual"))
    VoidGUI.updateDisplay()
end

function AdaptiveThinking.toggle()
    if AdaptiveThinking.active then
        AdaptiveThinking.manualOverride = false
        AdaptiveThinking.stop("manual toggle")
    else
        AdaptiveThinking.manualOverride = true
        AdaptiveThinking.start("manual toggle")
    end
end

-- ---- PLAYER SAMPLING ----

function AdaptiveThinking.samplePlayers()
    if not AdaptiveThinking.active then return end
    local place = getCurrentPlace()
    if not place then return end
    local session = AdaptiveThinking.observedSession
    if not session then return end

    local allPlayers = Players:GetPlayers()
    local activeSamples = 0

    for _, p in ipairs(allPlayers) do
        if p == LocalPlayer then continue end
        if not p.Character then continue end

        local hrp = p.Character:FindFirstChild("HumanoidRootPart")
        local hum = p.Character:FindFirstChildOfClass("Humanoid")
        if not hrp or not hum then continue end

        local pos = hrp.Position
        local pid = p.UserId
        activeSamples += 1

        -- Initialize tracker for this player
        if not AdaptiveThinking.playerTrackers[pid] then
            AdaptiveThinking.playerTrackers[pid] = {
                name        = p.Name,
                lastPos     = pos,
                lastMoveT   = os.time(),
                pathPoints  = {},
                isRunning   = false,
                fleeCount   = 0,
            }
            session.playerPaths[tostring(pid)] = {
                name   = p.Name,
                points = {},
            }
        end

        local tracker = AdaptiveThinking.playerTrackers[pid]
        local delta   = (pos - tracker.lastPos).Magnitude

        -- Only record if player actually moved (>2 studs, same threshold as self-observer)
        if delta >= 2 then
            tracker.lastPos  = pos
            tracker.lastMoveT= os.time()

            -- Log point to this player's path
            local point = {
                pos = {x = pos.X, y = pos.Y, z = pos.Z},
                t   = os.time() - AdaptiveThinking.sessionStart,
                spd = hum.WalkSpeed,
            }
            table.insert(tracker.pathPoints, point)
            if session.playerPaths[tostring(pid)] then
                table.insert(session.playerPaths[tostring(pid)].points, point)
            end

            -- Update path frequency map for this grid cell
            local posKey = math.floor(pos.X/10) .. "_" .. math.floor(pos.Z/10)
            AdaptiveThinking.pathFrequency[posKey] =
                (AdaptiveThinking.pathFrequency[posKey] or 0) + 1

            -- Add to observed session as a weighted movement action
            table.insert(session.actions, {
                type        = "move",
                source      = "observed_" .. p.Name,
                trustWeight = AdaptiveThinking.TRUST_WEIGHT,
                data        = point,
            })
            table.insert(session.movements, point)
        end

        -- Detect fleeing (high walkspeed = running)
        local isFleeing = hum.WalkSpeed > 20
        if isFleeing and not tracker.isRunning then
            tracker.isRunning = true
            tracker.fleeCount += 1

            -- Record flee event
            table.insert(session.actions, {
                type   = "flee_observed",
                source = p.Name,
                data   = {
                    pos = {x = pos.X, y = pos.Y, z = pos.Z},
                    t   = os.time() - AdaptiveThinking.sessionStart,
                }
            })

            -- If 3+ players flee from roughly same area = strong danger signal
            local nearbyFleeing = 0
            for _, p2 in ipairs(allPlayers) do
                if p2 ~= LocalPlayer and p2 ~= p and p2.Character then
                    local hrp2 = p2.Character:FindFirstChild("HumanoidRootPart")
                    local hum2 = p2.Character:FindFirstChildOfClass("Humanoid")
                    if hrp2 and hum2 and hum2.WalkSpeed > 20 then
                        if (hrp2.Position - pos).Magnitude < 60 then
                            nearbyFleeing += 1
                        end
                    end
                end
            end
            if nearbyFleeing >= 2 then
                -- Tag flee origin as danger
                local fleeKey = math.floor(pos.X/10) .. "_" .. math.floor(pos.Z/10)
                place.mentalMap.landmarks[fleeKey] = {
                    pos    = {x = pos.X, y = pos.Y, z = pos.Z},
                    tag    = "danger",
                    visits = nearbyFleeing,
                    note   = "atm:mass_flee",
                }
                print("[VoidAI:ATM] Mass flee detected — tagged danger at " .. fleeKey)
            end

        elseif not isFleeing then
            tracker.isRunning = false
        end
    end

    -- Update sample rate based on player count (more players = slower sample to avoid lag)
    if activeSamples > 10 then
        AdaptiveThinking.sampleRate = 0.8
    elseif activeSamples > 5 then
        AdaptiveThinking.sampleRate = 0.5
    else
        AdaptiveThinking.sampleRate = 0.3
    end
end

-- ---- CROSS-REFERENCE & ZONE TAGGING ----

function AdaptiveThinking.processPathFrequency()
    if not AdaptiveThinking.active then return end
    local place = getCurrentPlace()
    if not place then return end

    for posKey, count in pairs(AdaptiveThinking.pathFrequency) do
        local parts = posKey:split("_")
        if #parts >= 2 then
            local wx = tonumber(parts[1]) * 10
            local wz = tonumber(parts[2]) * 10

            if not place.mentalMap.landmarks[posKey] then
                place.mentalMap.landmarks[posKey] = {
                    pos    = {x = wx, y = 0, z = wz},
                    tag    = "visited",
                    visits = 0,
                    note   = "atm",
                }
            end

            local lm = place.mentalMap.landmarks[posKey]
            -- Increment visits by weighted count
            lm.visits = (lm.visits or 0) + math.floor(count * AdaptiveThinking.TRUST_WEIGHT)

            -- Cross-reference: 3+ distinct players passed here = likely safe/important route
            if count >= 3 and lm.tag == "visited" then
                lm.tag  = "safe"
                lm.note = "atm:high_traffic"
            end
        end
    end
end

-- ---- MERGE INTO BRAIN ----

function AdaptiveThinking.mergeIntoBrain()
    local place = getCurrentPlace()
    if not place or not AdaptiveThinking.observedSession then return end

    local session = AdaptiveThinking.observedSession

    -- Process path frequency → zone tags
    AdaptiveThinking.processPathFrequency()

    -- Only add observed session if it has meaningful data
    local actionCount = #session.actions
    if actionCount < 10 then
        print("[VoidAI:ATM] Observed session too sparse (" .. actionCount .. " actions) — discarded")
        return
    end

    -- Add to place sessions with trust weight metadata
    table.insert(place.sessions, session)
    place.totalActions = (place.totalActions or 0) + actionCount

    -- Recalculate confidence, but dampen ATM contribution
    -- ATM sessions count as 0.6x a normal session for confidence
    local atmSessions = 0
    local selfSessions = 0
    for _, s in ipairs(place.sessions) do
        if s.type == "adaptive_observed" then
            atmSessions += 1
        else
            selfSessions += 1
        end
    end

    -- Weighted confidence: self sessions worth 1.0, ATM sessions worth 0.6
    local weightedCount = selfSessions + atmSessions * AdaptiveThinking.TRUST_WEIGHT
    if weightedCount > 0 then
        local totalMoves, totalUI, totalSeq = 0, 0, 0
        for _, s in ipairs(place.sessions) do
            local w = (s.type == "adaptive_observed") and AdaptiveThinking.TRUST_WEIGHT or 1.0
            totalMoves = totalMoves + #(s.movements  or {}) * w
            totalUI    = totalUI    + #(s.uiClicks   or {}) * w
            totalSeq   = totalSeq   + #(s.sequences  or {}) * w
        end
        place.confidence.movement  = math.min(100, math.floor(math.log(totalMoves + 1) * 15))
        place.confidence.ui        = math.min(100, math.floor(math.log(totalUI    + 1) * 20))
        place.confidence.sequences = math.min(100, math.floor(math.log(totalSeq   + 1) * 25))
    end

    -- Run sequence analysis on the new data
    SequenceDetector.analyze()

    saveBrain()
    print(string.format("[VoidAI:ATM] Merged — %d actions from %d players | Conf: Move%d%% UI%d%%",
        actionCount,
        session.playerCount,
        place.confidence.movement,
        place.confidence.ui))
end

-- ---- MAIN HEARTBEAT HOOK ----

RunService.Heartbeat:Connect(function(dt)
    -- AFK timer (only auto-trigger if not manual override)
    if not AdaptiveThinking.manualOverride then
        local idleTime = os.time() - lastInputTime
        if idleTime >= AdaptiveThinking.afkThreshold and not AdaptiveThinking.active then
            -- Auto-start ATM if player appears to be AFK
            -- Only if not already in another active AI mode
            if AIState.mode == "idle" or AIState.mode == "observing" then
                AdaptiveThinking.start("AFK auto-trigger (" .. idleTime .. "s idle)")
            end
        end
    end

    -- Sample players while ATM is active
    if AdaptiveThinking.active then
        AdaptiveThinking.tickAccum += dt
        if AdaptiveThinking.tickAccum >= AdaptiveThinking.sampleRate then
            AdaptiveThinking.tickAccum = 0
            AdaptiveThinking.samplePlayers()

            -- Process path frequency every 15 seconds
            local elapsed = os.time() - AdaptiveThinking.sessionStart
            if elapsed > 0 and elapsed % 15 == 0 then
                AdaptiveThinking.processPathFrequency()
            end
        end
    end
end)

-- ---- GUI INTEGRATION ----
-- Add ATM button and status to main tab after GUI builds

task.spawn(function()
    task.wait(1)
    if not screenGui then return end
    local panel = screenGui:FindFirstChild("VoidAIPanel")
    if not panel then return end

    -- Find main tab ScrollingFrame (first one)
    local scrollFrames = {}
    for _, child in ipairs(panel:GetChildren()) do
        if child:IsA("ScrollingFrame") then
            table.insert(scrollFrames, child)
        end
    end
    local mainScroll = scrollFrames[1]
    if not mainScroll then return end

    -- ATM toggle button
    local atmBtn = makeButton(mainScroll,
        "🧠 Adaptive Thinking: OFF",
        UDim2.new(1,-12,0,28),
        UDim2.new(0,6,0,310),
        COLORS.panel)
    atmBtn.TextSize = 11

    -- AFK timer label
    local atmStatus = makeLabel(mainScroll,
        "AFK: 0s / 30s — 0 players watched",
        UDim2.new(1,-12,0,14),
        UDim2.new(0,6,0,342),
        9, COLORS.textDim)

    atmBtn.MouseButton1Click:Connect(function()
        AdaptiveThinking.toggle()
        if AdaptiveThinking.active then
            atmBtn.Text             = "🧠 Adaptive Thinking: ON"
            atmBtn.BackgroundColor3 = COLORS.accent
        else
            atmBtn.Text             = "🧠 Adaptive Thinking: OFF"
            atmBtn.BackgroundColor3 = COLORS.panel
        end
    end)

    -- Live status updater for ATM button
    task.spawn(function()
        while true do
            task.wait(1)
            local idleTime = os.time() - lastInputTime
            local pCount   = math.max(0, #Players:GetPlayers() - 1)
            if AdaptiveThinking.active then
                local elapsed = os.time() - AdaptiveThinking.sessionStart
                local actions = AdaptiveThinking.observedSession
                    and #AdaptiveThinking.observedSession.actions or 0
                atmStatus.Text = string.format(
                    "ATM: %ds active | %d players | %d actions learned",
                    elapsed, pCount, actions)
                atmStatus.TextColor3 = COLORS.success
                atmBtn.Text             = "🧠 Adaptive Thinking: ON"
                atmBtn.BackgroundColor3 = COLORS.accent
            else
                local threshold = AdaptiveThinking.afkThreshold
                local remaining = math.max(0, threshold - idleTime)
                atmStatus.Text = string.format(
                    "AFK: %ds idle | auto-starts in %ds | %d players",
                    math.floor(idleTime), math.floor(remaining), pCount)
                atmStatus.TextColor3 = COLORS.textDim
                atmBtn.Text             = "🧠 Adaptive Thinking: OFF"
                atmBtn.BackgroundColor3 = COLORS.panel
            end
        end
    end)

    -- Expand canvas to fit new button
    mainScroll.CanvasSize = UDim2.new(0,0,0,580)
end)

-- ---- CHAT COMMAND ----
CHAT_COMMANDS["atm"] = function()
    AdaptiveThinking.toggle()
    VoidGUI.notify(AdaptiveThinking.active and
        "🧠 ATM ON — watching players" or
        "🧠 ATM OFF — brain saved")
end

CHAT_COMMANDS["afk"] = function()
    -- Set AFK threshold to 0 to force immediate ATM trigger
    lastInputTime = 0
    VoidGUI.notify("AFK forced — ATM will start shortly")
end

-- ---- STATS TAB INTEGRATION ----
-- Patch stats display to show ATM session count separately

local _origBuildStats = VoidGUI.buildStatsTab
function VoidGUI.buildStatsTab(container)
    _origBuildStats(container)
    -- Add ATM-specific stat rows
    local place = getCurrentPlace()
    if not place then return end

    local atmCount  = 0
    local selfCount = 0
    for _, s in ipairs(place.sessions) do
        if s.type == "adaptive_observed" then
            atmCount += 1
        else
            selfCount += 1
        end
    end

    makeLabel(container,
        "Self Sessions",
        UDim2.new(0.6,0,0,16),
        UDim2.new(0,6,0,195), 11, COLORS.textDim)
    makeLabel(container,
        tostring(selfCount),
        UDim2.new(0.4,0,0,16),
        UDim2.new(0.6,0,0,195), 11, COLORS.success)

    makeLabel(container,
        "ATM Sessions (0.6x)",
        UDim2.new(0.6,0,0,16),
        UDim2.new(0,6,0,213), 11, COLORS.textDim)
    makeLabel(container,
        tostring(atmCount),
        UDim2.new(0.4,0,0,16),
        UDim2.new(0.6,0,0,213), 11, COLORS.accent)
end

-- ============================================================
-- SECTION 34: EXTENDED ADAPTIVE THINKING (EAT)
-- ============================================================
--[[
    Extended Adaptive Thinking (EAT):
    Runs while the USER plays normally — no lending, no control taken.
    Learns from BOTH the user's own actions AND other players simultaneously.
    Layered with AI-mimicking systems:
      - Pattern Reinforcement (dual-source confirmation)
      - Attention Focus (prioritize active players)
      - Action Chunking / Macro system
      - Forgetting Curve (session time decay)
      - Next-Action Prediction Engine
      - Working Memory Buffer (short-term vs long-term)
      - Behavior Clustering (path generalization)
]]

local EAT = {}

-- ---- STATE ----
EAT.active          = false
EAT.sessionStart    = 0
EAT.tickAccum       = 0
EAT.sampleRate      = 0.25       -- base sample rate, adapts by FPS + player count

-- ---- WORKING MEMORY BUFFER (short-term) ----
-- Holds last N actions seen by user. Checked before long-term brain.
EAT.workingMemory   = {}         -- circular buffer
EAT.WM_CAPACITY     = 20        -- max entries

-- ---- MACRO / CHUNK SYSTEM ----
EAT.macros          = {}         -- named action chunks
EAT.currentChunk    = {}         -- current chunk being built
EAT.CHUNK_MIN_LEN   = 3         -- minimum actions to form a chunk
EAT.CHUNK_MAX_LEN   = 8
EAT.chunkLabels = {
    ["move|move|move|ui_click"]          = "approach_and_interact",
    ["sprint_start|move|move|jump"]      = "parkour_sequence",
    ["move|ui_click|move|move"]          = "click_and_move",
    ["jump|move|move|move"]              = "jump_traverse",
    ["sprint_start|move|move|move"]      = "sprint_path",
    ["ui_click|ui_click|move"]           = "multi_interact",
    ["move|move|jump|move|move"]         = "gap_cross",
    ["ui_click|move|ui_click"]           = "ui_navigate",
    ["move|move|move|move|move"]         = "long_path",
    ["sprint_start|sprint_end|ui_click"] = "sprint_to_objective",
}

-- ---- ATTENTION SCORER ----
-- Each player gets a score 0-100 representing how worth-watching they are
EAT.attentionScores = {}   -- [userId] = score
EAT.attentionHistory= {}   -- [userId] = {lastPos, lastActionCount, lastUpdateTime}

-- ---- FORGETTING CURVE ----
-- Returns weight multiplier [0.0 - 1.0] based on session age in seconds
local FORGETTING_CURVE = {
    {days=0,   weight=1.00},
    {days=3,   weight=0.80},
    {days=7,   weight=0.60},
    {days=14,  weight=0.30},
    {days=30,  weight=0.10},
    {days=999, weight=0.05},
}
local SECONDS_PER_DAY = 86400

function EAT.getTimeDecayWeight(sessionTimestamp)
    if not sessionTimestamp then return 1.0 end
    local ageSeconds = os.time() - sessionTimestamp
    local ageDays    = ageSeconds / SECONDS_PER_DAY
    local weight     = 0.05
    for i = 1, #FORGETTING_CURVE - 1 do
        local a = FORGETTING_CURVE[i]
        local b = FORGETTING_CURVE[i + 1]
        if ageDays >= a.days and ageDays < b.days then
            -- Linear interpolation between curve points
            local t = (ageDays - a.days) / (b.days - a.days)
            weight  = a.weight + (b.weight - a.weight) * t
            break
        end
    end
    return math.max(0.05, weight)
end

-- ---- PATTERN REINFORCEMENT ----
-- Tracks which sequences were seen from BOTH self AND observed players
EAT.reinforcedPatterns = {}   -- sequenceKey -> {selfCount, observedCount, totalWeight}

function EAT.reinforcePattern(seqKey, source)
    if not EAT.reinforcedPatterns[seqKey] then
        EAT.reinforcedPatterns[seqKey] = {
            selfCount     = 0,
            observedCount = 0,
            totalWeight   = 0,
        }
    end
    local pat = EAT.reinforcedPatterns[seqKey]
    if source == "self" then
        pat.selfCount += 1
    else
        pat.observedCount += 1
    end
    -- Weight doubles when BOTH self and observed confirm it
    if pat.selfCount > 0 and pat.observedCount > 0 then
        pat.totalWeight = (pat.selfCount + pat.observedCount) * 2.0  -- dual-source bonus
    else
        pat.totalWeight = pat.selfCount * 1.0 + pat.observedCount * 0.6
    end
end

function EAT.getPatternWeight(seqKey)
    local pat = EAT.reinforcedPatterns[seqKey]
    if not pat then return 0 end
    return pat.totalWeight
end

-- ---- WORKING MEMORY ----

function EAT.pushWorkingMemory(action)
    table.insert(EAT.workingMemory, action)
    if #EAT.workingMemory > EAT.WM_CAPACITY then
        table.remove(EAT.workingMemory, 1)  -- drop oldest
    end
end

function EAT.getWorkingMemoryKey()
    -- Builds a sequence key from recent working memory entries
    local key = ""
    local start = math.max(1, #EAT.workingMemory - 4)
    for i = start, #EAT.workingMemory do
        key = key .. (EAT.workingMemory[i].type or "?") .. "|"
    end
    return key
end

-- ---- ACTION CHUNKING ----

function EAT.pushChunk(actionType)
    table.insert(EAT.currentChunk, actionType)
    if #EAT.currentChunk > EAT.CHUNK_MAX_LEN then
        table.remove(EAT.currentChunk, 1)
    end
    -- Check if current chunk matches any label
    EAT.detectMacro()
end

function EAT.detectMacro()
    if #EAT.currentChunk < EAT.CHUNK_MIN_LEN then return nil end

    -- Try matching from longest to shortest window
    for len = math.min(#EAT.currentChunk, EAT.CHUNK_MAX_LEN), EAT.CHUNK_MIN_LEN, -1 do
        local key = ""
        local startIdx = #EAT.currentChunk - len + 1
        for i = startIdx, #EAT.currentChunk do
            key = key .. EAT.currentChunk[i] .. "|"
        end
        -- Remove trailing |
        key = key:sub(1, -2)

        local label = EAT.chunkLabels[key]
        if label then
            -- Register or reinforce this macro
            if not EAT.macros[label] then
                EAT.macros[label] = {
                    label    = label,
                    key      = key,
                    count    = 0,
                    actions  = {},
                    lastSeen = 0,
                }
            end
            EAT.macros[label].count   += 1
            EAT.macros[label].lastSeen = os.time()

            -- Save actual action data snapshot for replay
            local place = getCurrentPlace()
            if place and AIState.currentSession then
                local total = #AIState.currentSession.actions
                local slice = {}
                for i = math.max(1, total - len + 1), total do
                    table.insert(slice, AIState.currentSession.actions[i])
                end
                EAT.macros[label].actions = slice
            end

            print("[VoidAI:EAT] Macro detected: " .. label .. " (x" .. EAT.macros[label].count .. ")")
            return label
        end
    end
    return nil
end

function EAT.saveMacros()
    -- Persist macros into brain file
    local place = getCurrentPlace()
    if not place then return end
    place.macros = EAT.macros
end

function EAT.loadMacros()
    local place = getCurrentPlace()
    if not place then return end
    if place.macros then
        EAT.macros = place.macros
        local count = 0
        for _ in pairs(EAT.macros) do count += 1 end
        if count > 0 then
            print("[VoidAI:EAT] Loaded " .. count .. " macros from brain")
        end
    end
end

-- ---- ATTENTION FOCUS ----

function EAT.updateAttentionScore(player)
    local pid  = player.UserId
    local char = player.Character
    if not char then return 0 end

    local hrp = char:FindFirstChild("HumanoidRootPart")
    local hum = char:FindFirstChildOfClass("Humanoid")
    if not hrp or not hum then return 0 end

    if not EAT.attentionHistory[pid] then
        EAT.attentionHistory[pid] = {
            lastPos         = hrp.Position,
            lastActionCount = 0,
            lastUpdateTime  = os.time(),
            score           = 50,
        }
    end

    local hist     = EAT.attentionHistory[pid]
    local timeDelta= os.time() - hist.lastUpdateTime
    if timeDelta < 0.5 then return hist.score end

    local score = 0

    -- Movement activity (0-40 pts)
    local moveDelta = (hrp.Position - hist.lastPos).Magnitude
    score += math.min(40, moveDelta * 2)

    -- Walk speed (running = interesting, 0-20 pts)
    if hum.WalkSpeed > 20 then score += 20
    elseif hum.WalkSpeed > 14 then score += 10 end

    -- Health change detection (combat/damage = interesting, 0-20 pts)
    if hum.Health < hum.MaxHealth * 0.9 then score += 20
    elseif hum.Health < hum.MaxHealth then score += 10 end

    -- Idle penalty
    if moveDelta < 1 and hum.WalkSpeed <= 0 then
        score = math.max(0, score - 30)
    end

    -- Smooth score with exponential moving average
    hist.score         = hist.score * 0.7 + score * 0.3
    hist.lastPos       = hrp.Position
    hist.lastUpdateTime= os.time()

    EAT.attentionScores[pid] = hist.score
    return hist.score
end

function EAT.getSortedPlayers()
    -- Returns players sorted by attention score descending
    local scored = {}
    for _, p in ipairs(Players:GetPlayers()) do
        if p ~= LocalPlayer then
            local score = EAT.updateAttentionScore(p)
            table.insert(scored, {player = p, score = score})
        end
    end
    table.sort(scored, function(a, b) return a.score > b.score end)
    return scored
end

-- ---- BEHAVIOR CLUSTERING ----
-- Groups similar movement positions into cluster centroids
-- So AI generalizes paths instead of memorizing exact coordinates

EAT.clusters        = {}   -- list of {centroid, members, tag}
EAT.CLUSTER_RADIUS  = 15  -- studs — positions within this distance merge

function EAT.addToCluster(pos, tag)
    -- Find nearest existing cluster
    local nearest    = nil
    local nearestDist= math.huge

    for i, cluster in ipairs(EAT.clusters) do
        local c   = cluster.centroid
        local dist= (Vector3.new(c.x,c.y,c.z) - pos).Magnitude
        if dist < nearestDist then
            nearestDist = dist
            nearest     = i
        end
    end

    if nearest and nearestDist <= EAT.CLUSTER_RADIUS then
        -- Merge into existing cluster — update centroid
        local cluster = EAT.clusters[nearest]
        local n       = #cluster.members + 1
        cluster.centroid = {
            x = (cluster.centroid.x * (n-1) + pos.X) / n,
            y = (cluster.centroid.y * (n-1) + pos.Y) / n,
            z = (cluster.centroid.z * (n-1) + pos.Z) / n,
        }
        table.insert(cluster.members, {x=pos.X, y=pos.Y, z=pos.Z})
        if tag and tag ~= "visited" then cluster.tag = tag end
    else
        -- Create new cluster
        table.insert(EAT.clusters, {
            centroid = {x=pos.X, y=pos.Y, z=pos.Z},
            members  = {{x=pos.X, y=pos.Y, z=pos.Z}},
            tag      = tag or "visited",
            weight   = 1,
        })
    end
end

function EAT.getNearestCluster(pos)
    local nearest    = nil
    local nearestDist= math.huge
    for _, cluster in ipairs(EAT.clusters) do
        local c   = cluster.centroid
        local dist= (Vector3.new(c.x,c.y,c.z) - pos).Magnitude
        if dist < nearestDist then
            nearestDist = dist
            nearest     = cluster
        end
    end
    return nearest, nearestDist
end

function EAT.saveClusters()
    local place = getCurrentPlace()
    if not place then return end
    -- Cap at 500 clusters to avoid bloat
    if #EAT.clusters > 500 then
        -- Keep only the most-membered clusters
        table.sort(EAT.clusters, function(a,b)
            return #a.members > #b.members
        end)
        local trimmed = {}
        for i = 1, 500 do trimmed[i] = EAT.clusters[i] end
        EAT.clusters = trimmed
    end
    place.clusters = EAT.clusters
end

function EAT.loadClusters()
    local place = getCurrentPlace()
    if not place then return end
    if place.clusters then
        EAT.clusters = place.clusters
        print("[VoidAI:EAT] Loaded " .. #EAT.clusters .. " behavior clusters")
    end
end

-- ---- NEXT-ACTION PREDICTION ENGINE ----
-- Scores candidate actions based on:
--   1. Working memory context (recent actions)
--   2. Reinforced pattern weights
--   3. Cluster proximity
--   4. Forgetting-curve-weighted session history

EAT.lastPrediction  = nil
EAT.predictionScores= {}

local CANDIDATE_ACTIONS = {
    "move", "jump", "sprint_start", "ui_click",
    "hide", "flee", "interact", "wait",
}

function EAT.predictNextAction()
    local place = getCurrentPlace()
    if not place then return nil end

    local wmKey    = EAT.getWorkingMemoryKey()
    local scores   = {}

    for _, candidate in ipairs(CANDIDATE_ACTIONS) do
        local score = 0

        -- 1. Working memory match: does recent history predict this next?
        local testKey = wmKey .. candidate
        for seqKey, pat in pairs(EAT.reinforcedPatterns) do
            if seqKey:find(testKey, 1, true) then
                score += EAT.getPatternWeight(seqKey) * 10
            end
        end

        -- 2. Session history: how often does this action follow current context?
        for _, session in ipairs(place.sessions) do
            local decay  = EAT.getTimeDecayWeight(session.startTime or session.id)
            local trust  = (session.type == "adaptive_observed") and 0.6 or 1.0
            local actions= session.actions or {}
            for i = 2, #actions do
                if actions[i-1].type == (EAT.workingMemory[#EAT.workingMemory] or {}).type then
                    if actions[i].type == candidate then
                        score += decay * trust * 5
                    end
                end
            end
        end

        -- 3. Threat modifier: if threat detected, boost survival actions
        if AIState.threatDetected then
            if candidate == "hide" or candidate == "flee" then
                score += 50
            end
            if candidate == "ui_click" or candidate == "wait" then
                score -= 20
            end
        end

        -- 4. Persona modifier
        if AIState.persona == "Aggressive" then
            if candidate == "sprint_start" or candidate == "move" then score += 10 end
            if candidate == "hide" or candidate == "wait" then score -= 15 end
        elseif AIState.persona == "Passive" then
            if candidate == "wait" or candidate == "ui_click" then score += 10 end
            if candidate == "sprint_start" then score -= 10 end
        end

        scores[candidate] = math.max(0, score)
    end

    -- Find highest scoring candidate
    local bestAction = "move"
    local bestScore  = -1
    for action, score in pairs(scores) do
        if score > bestScore then
            bestScore  = score
            bestAction = action
        end
    end

    EAT.lastPrediction   = bestAction
    EAT.predictionScores = scores
    return bestAction, bestScore, scores
end

-- ---- FORGETTING CURVE APPLICATION ----
-- Recalculates confidence using time-decayed session weights
-- Called periodically so confidence reflects recent relevance

function EAT.applyForgettingCurve()
    local place = getCurrentPlace()
    if not place or #place.sessions == 0 then return end

    local totalMoves, totalUI, totalSeq = 0, 0, 0

    for _, session in ipairs(place.sessions) do
        local decay = EAT.getTimeDecayWeight(session.startTime or session.id)
        local trust = (session.type == "adaptive_observed")
            and AdaptiveThinking.TRUST_WEIGHT or 1.0
        local w = decay * trust

        totalMoves = totalMoves + #(session.movements  or {}) * w
        totalUI    = totalUI    + #(session.uiClicks   or {}) * w
        totalSeq   = totalSeq   + #(session.sequences  or {}) * w
    end

    place.confidence.movement  = math.min(100, math.floor(math.log(totalMoves  + 1) * 15))
    place.confidence.ui        = math.min(100, math.floor(math.log(totalUI     + 1) * 20))
    place.confidence.sequences = math.min(100, math.floor(math.log(totalSeq    + 1) * 25))
end

-- ---- CORE: OBSERVE SELF TICK ----
-- Runs while EAT is active AND user is actively playing

function EAT.observeSelf()
    local char = LocalPlayer.Character
    if not char then return end
    local hrp  = char:FindFirstChild("HumanoidRootPart")
    local hum  = char:FindFirstChildOfClass("Humanoid")
    if not hrp or not hum then return end

    local pos  = hrp.Position

    -- Record movement to working memory
    local moveDelta = (pos - Observer.lastPosition).Magnitude
    if moveDelta >= 2 then
        local action = {
            type   = "move",
            source = "self",
            data   = {pos={x=pos.X,y=pos.Y,z=pos.Z}, t=os.time()-AIState.sessionStart},
        }
        EAT.pushWorkingMemory(action)
        EAT.pushChunk("move")

        -- Add to behavior clusters
        EAT.addToCluster(pos, "visited")

        -- Reinforce this as a self-observed pattern
        local wmKey = EAT.getWorkingMemoryKey()
        EAT.reinforcePattern(wmKey, "self")
    end
end

-- ---- CORE: OBSERVE OTHERS TICK ----
-- Attention-weighted player sampling

function EAT.observeOthers()
    local place = getCurrentPlace()
    if not place then return end

    local sortedPlayers = EAT.getSortedPlayers()

    -- Top 30% of players get 3x more samples, bottom 20% are skipped
    local total = #sortedPlayers
    if total == 0 then return end

    for rank, entry in ipairs(sortedPlayers) do
        local p     = entry.player
        local score = entry.score

        -- Skip idle players entirely (score < 15)
        if score < 15 then continue end

        -- Sub-sample low-attention players (50% chance)
        local normalized = rank / total  -- 0=highest attention, 1=lowest
        if normalized > 0.7 then
            if math.random() > 0.5 then continue end
        end

        local char = p.Character
        if not char then continue end
        local hrp  = char:FindFirstChild("HumanoidRootPart")
        local hum  = char:FindFirstChildOfClass("Humanoid")
        if not hrp or not hum then continue end

        local pos = hrp.Position

        -- Track this player's previous position
        local pid = p.UserId
        if not AdaptiveThinking.playerTrackers[pid] then
            AdaptiveThinking.playerTrackers[pid] = {
                name      = p.Name,
                lastPos   = pos,
                lastMoveT = os.time(),
                pathPoints= {},
                isRunning = false,
                fleeCount = 0,
            }
        end

        local tracker  = AdaptiveThinking.playerTrackers[pid]
        local moveDelta= (pos - tracker.lastPos).Magnitude
        if moveDelta < 2 then continue end

        tracker.lastPos = pos

        -- Build action from observation
        local observedAction = {
            type        = "move",
            source      = "observed_" .. p.Name,
            trustWeight = AdaptiveThinking.TRUST_WEIGHT,
            data        = {
                pos = {x=pos.X, y=pos.Y, z=pos.Z},
                t   = os.time() - EAT.sessionStart,
                spd = hum.WalkSpeed,
            },
        }

        -- Push to working memory with lower trust
        EAT.pushWorkingMemory(observedAction)

        -- Pattern reinforcement — observed source
        local wmKey = EAT.getWorkingMemoryKey()
        EAT.reinforcePattern(wmKey, "observed")

        -- Add to clusters
        EAT.addToCluster(pos, "visited")

        -- Detect fleeing
        if hum.WalkSpeed > 20 and not tracker.isRunning then
            tracker.isRunning = true
            EAT.addToCluster(pos, "danger")
        elseif hum.WalkSpeed <= 16 then
            tracker.isRunning = false
        end

        -- Check if same path was also taken by self recently
        -- If so, reinforce double
        local selfNearby = false
        if LocalPlayer.Character then
            local selfHRP = LocalPlayer.Character:FindFirstChild("HumanoidRootPart")
            if selfHRP and (selfHRP.Position - pos).Magnitude < EAT.CLUSTER_RADIUS then
                selfNearby = true
            end
        end
        if selfNearby then
            EAT.reinforcePattern(wmKey, "self")  -- dual-source reinforce
        end
    end
end

-- ---- MAIN EAT TICK (plugged into RunService) ----

RunService.Heartbeat:Connect(function(dt)
    if not EAT.active then return end

    EAT.tickAccum += dt

    -- Adapt sample rate same as ATM
    local playerCount = #Players:GetPlayers() - 1
    if FPS > 50 and playerCount <= 5 then
        EAT.sampleRate = 0.2
    elseif FPS > 30 then
        EAT.sampleRate = 0.35
    else
        EAT.sampleRate = 0.6
    end

    if EAT.tickAccum < EAT.sampleRate then return end
    EAT.tickAccum = 0

    -- Observe self
    EAT.observeSelf()

    -- Observe others
    EAT.observeOthers()

    -- Run prediction every 2 seconds (low priority)
    if AIState.tickCounter % 8 == 0 then
        EAT.predictNextAction()
    end

    -- Apply forgetting curve every 30 seconds
    if AIState.tickCounter % 120 == 0 then
        EAT.applyForgettingCurve()
    end

    -- Auto-save macros + clusters every 60 seconds
    if AIState.tickCounter % 240 == 0 then
        EAT.saveMacros()
        EAT.saveClusters()
        saveBrain()
    end
end)

-- ---- PUBLIC API ----

function EAT.start()
    if EAT.active then return end
    EAT.active        = true
    EAT.sessionStart  = os.time()
    EAT.workingMemory = {}
    EAT.currentChunk  = {}
    AdaptiveThinking.playerTrackers = {}

    -- Load existing macros + clusters from brain
    EAT.loadMacros()
    EAT.loadClusters()

    -- Make sure observer session is also running (self-learning)
    if not AIState.isLearning then
        AIState.isLearning = true
        Observer.startSession()
    end

    VoidGUI.notify("⚡ Extended Adaptive Thinking ON")
    print("[VoidAI:EAT] Started — dual-source learning active")
end

function EAT.stop()
    if not EAT.active then return end
    EAT.active = false

    -- Finalize
    EAT.saveMacros()
    EAT.saveClusters()
    EAT.applyForgettingCurve()
    saveBrain()

    -- Print summary
    local macroCount   = 0
    local clusterCount = #EAT.clusters
    local patCount     = 0
    for _ in pairs(EAT.macros)             do macroCount += 1 end
    for _ in pairs(EAT.reinforcedPatterns) do patCount   += 1 end

    VoidGUI.notify(string.format("⚡ EAT OFF — %d macros | %d clusters | %d patterns",
        macroCount, clusterCount, patCount))
    print(string.format("[VoidAI:EAT] Stopped — Macros:%d Clusters:%d Patterns:%d",
        macroCount, clusterCount, patCount))
end

function EAT.toggle()
    if EAT.active then EAT.stop() else EAT.start() end
end

-- ---- GHOST/LEND INTEGRATION ----
-- GhostController checks EAT prediction before doing linear replay

local _eatWrappedStepReplay = GhostController.stepReplay
function GhostController.stepReplay()
    -- If EAT prediction is available and confident, use it
    if EAT.lastPrediction and EAT.predictionScores then
        local bestScore = EAT.predictionScores[EAT.lastPrediction] or 0
        if bestScore > 20 then  -- confidence threshold
            local char = LocalPlayer.Character
            local hum  = char and char:FindFirstChildOfClass("Humanoid")
            local hrp  = char and char:FindFirstChild("HumanoidRootPart")

            if EAT.lastPrediction == "jump" and hum then
                hum.Jump = true
                EAT.pushChunk("jump")
                return

            elseif EAT.lastPrediction == "sprint_start" and hum then
                hum.WalkSpeed = 24
                EAT.pushChunk("sprint_start")
                return

            elseif EAT.lastPrediction == "hide" then
                GhostController.runSurvivalPattern()
                return

            elseif EAT.lastPrediction == "flee" then
                GhostController.runSurvivalPattern()
                return

            elseif EAT.lastPrediction == "move" then
                -- Use nearest cluster centroid as target
                if hrp then
                    local cluster, dist = EAT.getNearestCluster(hrp.Position)
                    if cluster and dist > 5 then
                        local target = Vector3.new(
                            cluster.centroid.x,
                            cluster.centroid.y,
                            cluster.centroid.z
                        )
                        if hum then hum:MoveTo(target) end
                        EAT.pushChunk("move")
                        return
                    end
                end
            end
        end
    end

    -- Fallback: check macro library first
    local wmKey = EAT.getWorkingMemoryKey()
    for label, macro in pairs(EAT.macros) do
        if macro.count >= 2 and #macro.actions > 0 then
            -- Find a macro whose key is predicted by current context
            if wmKey:find(macro.key:sub(1,6), 1, true) then
                -- Replay macro actions
                task.spawn(function()
                    for _, action in ipairs(macro.actions) do
                        if AIState.mode ~= "ghost" and AIState.mode ~= "lend" then break end
                        if AIState.isPaused then break end
                        local char = LocalPlayer.Character
                        local hum  = char and char:FindFirstChildOfClass("Humanoid")
                        if action.type == "move" and action.data then
                            GhostController.executeMove(action.data)
                        elseif action.type == "ui_click" and action.data then
                            GhostController.executeUIClick(action.data)
                        elseif action.type == "jump" and hum then
                            hum.Jump = true
                        end
                        task.wait(0.15)
                    end
                end)
                return
            end
        end
    end

    -- Final fallback: linear replay
    _eatWrappedStepReplay()
end

-- ---- GUI INTEGRATION ----

task.spawn(function()
    task.wait(1.2)
    if not screenGui then return end
    local panel = screenGui:FindFirstChild("VoidAIPanel")
    if not panel then return end

    local scrollFrames = {}
    for _, child in ipairs(panel:GetChildren()) do
        if child:IsA("ScrollingFrame") then
            table.insert(scrollFrames, child)
        end
    end

    local mainScroll     = scrollFrames[1]
    local settingsScroll = scrollFrames[4]

    if mainScroll then
        -- EAT toggle button (below ATM button)
        local eatBtn = makeButton(mainScroll,
            "⚡ Ext. Adaptive Thinking: OFF",
            UDim2.new(1,-12,0,28),
            UDim2.new(0,6,0,356),
            COLORS.panel)
        eatBtn.TextSize = 11

        -- Prediction display
        local predLabel = makeLabel(mainScroll,
            "Prediction: —",
            UDim2.new(1,-12,0,14),
            UDim2.new(0,6,0,388),
            9, COLORS.textDim)

        -- Macro count
        local macroLabel = makeLabel(mainScroll,
            "Macros: 0 | Clusters: 0 | Patterns: 0",
            UDim2.new(1,-12,0,14),
            UDim2.new(0,6,0,404),
            9, COLORS.textDim)

        eatBtn.MouseButton1Click:Connect(function()
            EAT.toggle()
            if EAT.active then
                eatBtn.Text             = "⚡ Ext. Adaptive Thinking: ON"
                eatBtn.BackgroundColor3 = COLORS.accentHot
            else
                eatBtn.Text             = "⚡ Ext. Adaptive Thinking: OFF"
                eatBtn.BackgroundColor3 = COLORS.panel
            end
        end)

        -- Live update loop for EAT status
        task.spawn(function()
            while true do
                task.wait(1)
                if not mainScroll or not mainScroll.Parent then break end

                -- Update button state
                if EAT.active then
                    eatBtn.Text             = "⚡ Ext. Adaptive Thinking: ON"
                    eatBtn.BackgroundColor3 = COLORS.accentHot
                else
                    eatBtn.Text             = "⚡ Ext. Adaptive Thinking: OFF"
                    eatBtn.BackgroundColor3 = COLORS.panel
                end

                -- Update prediction label
                if EAT.lastPrediction then
                    local score = EAT.predictionScores and
                        EAT.predictionScores[EAT.lastPrediction] or 0
                    predLabel.Text = string.format("Prediction: %s (%.0f pts)",
                        EAT.lastPrediction, score)
                    predLabel.TextColor3 = score > 20 and COLORS.success or COLORS.textDim
                else
                    predLabel.Text       = "Prediction: — (not enough data)"
                    predLabel.TextColor3 = COLORS.textDim
                end

                -- Macro / cluster / pattern counts
                local macroCount   = 0
                local patternCount = 0
                for _ in pairs(EAT.macros)             do macroCount   += 1 end
                for _ in pairs(EAT.reinforcedPatterns) do patternCount += 1 end
                macroLabel.Text = string.format(
                    "Macros:%d | Clusters:%d | Patterns:%d",
                    macroCount, #EAT.clusters, patternCount)
            end
        end)

        mainScroll.CanvasSize = UDim2.new(0,0,0,650)
    end

    -- Add EAT stats to settings tab
    if settingsScroll then
        makeLabel(settingsScroll,
            "EAT Settings",
            UDim2.new(1,-12,0,14),
            UDim2.new(0,6,0,540),
            11, COLORS.accent).Font = Enum.Font.GothamBold

        -- AFK threshold slider (text input)
        makeLabel(settingsScroll,
            "AFK Threshold (sec)",
            UDim2.new(0.55,0,0,22),
            UDim2.new(0,6,0,558),
            10, COLORS.textDim)
        local afkBox = Instance.new("TextBox")
        afkBox.Size             = UDim2.new(0.35,0,0,22)
        afkBox.Position         = UDim2.new(0.6,0,0,558)
        afkBox.BackgroundColor3 = COLORS.panel
        afkBox.TextColor3       = COLORS.text
        afkBox.Text             = tostring(AdaptiveThinking.afkThreshold)
        afkBox.Font             = Enum.Font.GothamBold
        afkBox.TextSize         = 11
        afkBox.BorderSizePixel  = 0
        afkBox.Parent           = settingsScroll
        local afkc = Instance.new("UICorner"); afkc.CornerRadius = UDim.new(0,4); afkc.Parent = afkBox
        afkBox.FocusLost:Connect(function()
            local val = tonumber(afkBox.Text)
            if val and val >= 5 and val <= 300 then
                AdaptiveThinking.afkThreshold = val
                VoidGUI.notify("AFK threshold: " .. val .. "s")
            else
                afkBox.Text = tostring(AdaptiveThinking.afkThreshold)
            end
        end)

        -- Cluster radius input
        makeLabel(settingsScroll,
            "Cluster Radius (studs)",
            UDim2.new(0.55,0,0,22),
            UDim2.new(0,6,0,584),
            10, COLORS.textDim)
        local clusterBox = Instance.new("TextBox")
        clusterBox.Size             = UDim2.new(0.35,0,0,22)
        clusterBox.Position         = UDim2.new(0.6,0,0,584)
        clusterBox.BackgroundColor3 = COLORS.panel
        clusterBox.TextColor3       = COLORS.text
        clusterBox.Text             = tostring(EAT.CLUSTER_RADIUS)
        clusterBox.Font             = Enum.Font.GothamBold
        clusterBox.TextSize         = 11
        clusterBox.BorderSizePixel  = 0
        clusterBox.Parent           = settingsScroll
        local clc = Instance.new("UICorner"); clc.CornerRadius = UDim.new(0,4); clc.Parent = clusterBox
        clusterBox.FocusLost:Connect(function()
            local val = tonumber(clusterBox.Text)
            if val and val >= 5 and val <= 100 then
                EAT.CLUSTER_RADIUS = val
            else
                clusterBox.Text = tostring(EAT.CLUSTER_RADIUS)
            end
        end)

        -- Clear clusters button
        local clearBtn = makeButton(settingsScroll,
            "🗑 Clear Clusters",
            UDim2.new(1,-12,0,26),
            UDim2.new(0,6,0,610),
            COLORS.danger)
        clearBtn.MouseButton1Click:Connect(function()
            EAT.clusters = {}
            local place = getCurrentPlace()
            if place then place.clusters = {} end
            VoidGUI.notify("Clusters cleared")
        end)

        settingsScroll.CanvasSize = UDim2.new(0,0,0,660)
    end
end)

-- ---- CHAT COMMANDS ----
CHAT_COMMANDS["eat"]    = function() EAT.toggle() end
CHAT_COMMANDS["macros"] = function()
    local count = 0
    local names = {}
    for label, macro in pairs(EAT.macros) do
        count += 1
        table.insert(names, label .. "(x" .. macro.count .. ")")
    end
    VoidGUI.notify("Macros (" .. count .. "): " .. table.concat(names, ", "):sub(1, 80))
end
CHAT_COMMANDS["predict"] = function()
    local action, score = EAT.predictNextAction()
    VoidGUI.notify("Prediction: " .. (action or "none") .. " (" .. math.floor(score or 0) .. " pts)")
end
CHAT_COMMANDS["forget"] = function()
    EAT.applyForgettingCurve()
    VoidGUI.notify("Forgetting curve applied — confidence recalculated")
end

-- ---- INIT HOOK ----
-- Load EAT data when brain loads (runs after main init)
task.spawn(function()
    task.wait(2)
    EAT.loadMacros()
    EAT.loadClusters()
    local macroCount = 0
    for _ in pairs(EAT.macros) do macroCount += 1 end
    if macroCount > 0 then
        print("[VoidAI:EAT] " .. macroCount .. " macros ready | " .. #EAT.clusters .. " clusters loaded")
    end
end)

-- ============================================================
-- SECTION 35: ANOMALY DETECTION
-- ============================================================
--[[
    Watches for unusual events the AI hasn't seen before.
    Flags them as "anomalies" and stores them separately.
    Over time, anomalies that repeat get promoted to normal patterns.
    Mimics how a real AI handles out-of-distribution inputs.
]]

local AnomalyDetector = {}
AnomalyDetector.anomalies    = {}   -- {event, count, firstSeen, lastSeen}
AnomalyDetector.PROMOTE_COUNT= 3    -- seen N times = no longer anomaly

function AnomalyDetector.check(actionType, context)
    local place = getCurrentPlace()
    if not place or #place.sessions < 2 then return false end

    -- Check if this action type has ever appeared in any session
    local seen = false
    for _, session in ipairs(place.sessions) do
        for _, action in ipairs(session.actions or {}) do
            if action.type == actionType then
                seen = true
                break
            end
        end
        if seen then break end
    end

    if not seen then
        -- New anomaly
        local key = actionType .. "|" .. (context or "")
        if not AnomalyDetector.anomalies[key] then
            AnomalyDetector.anomalies[key] = {
                event     = actionType,
                context   = context,
                count     = 0,
                firstSeen = os.time(),
                lastSeen  = os.time(),
                promoted  = false,
            }
        end
        local a = AnomalyDetector.anomalies[key]
        a.count   += 1
        a.lastSeen = os.time()

        if a.count >= AnomalyDetector.PROMOTE_COUNT and not a.promoted then
            a.promoted = true
            print("[VoidAI:Anomaly] Promoted to pattern: " .. key)
            VoidGUI.notify("🔍 New pattern learned: " .. actionType)
        end
        return true
    end
    return false
end

function AnomalyDetector.getUnpromoted()
    local list = {}
    for _, a in pairs(AnomalyDetector.anomalies) do
        if not a.promoted then table.insert(list, a) end
    end
    table.sort(list, function(a,b) return a.count > b.count end)
    return list
end

-- ============================================================
-- SECTION 36: EMOTION / URGENCY STATE
-- ============================================================
--[[
    The AI maintains an internal urgency level (0-100).
    Low urgency = slow careful decisions.
    High urgency = fast aggressive decisions.
    Driven by: threat detection, player health, nearby fleeing players,
    time pressure signals (timers in UI), and persona.
    Mimics an emotional state layer found in modern RL agents.
]]

local UrgencySystem = {}
UrgencySystem.level    = 0      -- 0 = calm, 100 = panic
UrgencySystem.history  = {}     -- rolling history for smoothing
UrgencySystem.MAX_HIST = 10

function UrgencySystem.update()
    local rawUrgency = 0

    -- Threat detected = massive urgency spike
    if AIState.threatDetected then
        rawUrgency += 60
    end

    -- Player health
    local char = LocalPlayer.Character
    if char then
        local hum = char:FindFirstChildOfClass("Humanoid")
        if hum and hum.MaxHealth > 0 then
            local healthRatio = hum.Health / hum.MaxHealth
            if healthRatio < 0.3 then
                rawUrgency += 40
            elseif healthRatio < 0.6 then
                rawUrgency += 20
            end
        end
    end

    -- Nearby players fleeing
    local fleeCount = 0
    for _, p in ipairs(Players:GetPlayers()) do
        if p ~= LocalPlayer and p.Character then
            local h = p.Character:FindFirstChildOfClass("Humanoid")
            if h and h.WalkSpeed > 20 then fleeCount += 1 end
        end
    end
    rawUrgency += math.min(30, fleeCount * 10)

    -- Persona modifier
    if AIState.persona == "Aggressive" then
        rawUrgency = rawUrgency * 1.3
    elseif AIState.persona == "Passive" then
        rawUrgency = rawUrgency * 0.6
    end

    rawUrgency = math.min(100, rawUrgency)

    -- Smooth with rolling average
    table.insert(UrgencySystem.history, rawUrgency)
    if #UrgencySystem.history > UrgencySystem.MAX_HIST then
        table.remove(UrgencySystem.history, 1)
    end
    local sum = 0
    for _, v in ipairs(UrgencySystem.history) do sum += v end
    UrgencySystem.level = sum / #UrgencySystem.history
end

function UrgencySystem.getLabel()
    if UrgencySystem.level >= 75 then return "PANIC",  COLORS.danger
    elseif UrgencySystem.level >= 50 then return "HIGH",  COLORS.warning
    elseif UrgencySystem.level >= 25 then return "MED",   COLORS.accent
    else                                   return "CALM",  COLORS.success
    end
end

-- Wire urgency into replay speed — higher urgency = faster action cadence
local function getReplayInterval()
    local base = 0.2
    local urgencyFactor = 1 - (UrgencySystem.level / 100) * 0.7
    return math.max(0.05, base * urgencyFactor)
end

-- Update urgency every second in heartbeat
local lastUrgencyTick = 0
RunService.Heartbeat:Connect(function(dt)
    lastUrgencyTick += dt
    if lastUrgencyTick >= 1 then
        lastUrgencyTick = 0
        UrgencySystem.update()
    end
end)

-- ============================================================
-- SECTION 37: GOAL INFERENCE ENGINE
-- ============================================================
--[[
    Tries to infer what the player's current goal is
    based on observed actions + mental map context.
    Goals feed into the prediction engine and LLM prompts.
    Mimics goal-directed planning in modern AI agents.
]]

local GoalEngine = {}
GoalEngine.currentGoal   = "explore"   -- default
GoalEngine.goalHistory   = {}
GoalEngine.goalScores    = {}

local GOAL_SIGNALS = {
    explore     = {"move", "move", "move", "move"},
    combat      = {"sprint_start", "jump", "move", "sprint_start"},
    interact_ui = {"ui_click", "ui_click", "move"},
    collect     = {"move", "ui_click", "move", "ui_click"},
    escape      = {"sprint_start", "move", "jump", "sprint_start", "move"},
    idle        = {"wait", "wait", "wait"},
}

function GoalEngine.infer()
    if #EAT.workingMemory < 4 then return GoalEngine.currentGoal end

    -- Build recent action string
    local recent = {}
    local start  = math.max(1, #EAT.workingMemory - 5)
    for i = start, #EAT.workingMemory do
        table.insert(recent, EAT.workingMemory[i].type or "?")
    end

    GoalEngine.goalScores = {}
    for goal, signals in pairs(GOAL_SIGNALS) do
        local score = 0
        for i, sig in ipairs(signals) do
            if recent[i] == sig then score += 1 end
        end
        GoalEngine.goalScores[goal] = score / #signals
    end

    -- Threat override
    if AIState.threatDetected then
        GoalEngine.goalScores["escape"] = 1.0
    end

    -- Pick highest
    local bestGoal  = GoalEngine.currentGoal
    local bestScore = 0
    for goal, score in pairs(GoalEngine.goalScores) do
        if score > bestScore then
            bestScore = score
            bestGoal  = goal
        end
    end

    if bestGoal ~= GoalEngine.currentGoal then
        table.insert(GoalEngine.goalHistory, {
            from = GoalEngine.currentGoal,
            to   = bestGoal,
            t    = os.time(),
        })
        GoalEngine.currentGoal = bestGoal
        print("[VoidAI:Goal] Inferred goal: " .. bestGoal)
    end

    return GoalEngine.currentGoal
end

-- Patch LLM prompt to include goal
local _origBuildPrompt = LLMController.buildPrompt
function LLMController.buildPrompt(gameState)
    local base = _origBuildPrompt(gameState)
    if not base then return nil end
    local urgLabel = UrgencySystem.getLabel()
    local goal     = GoalEngine.infer()
    local anomalies= AnomalyDetector.getUnpromoted()
    local anomStr  = ""
    for i, a in ipairs(anomalies) do
        if i > 3 then break end
        anomStr = anomStr .. a.event .. "(x" .. a.count .. ") "
    end
    return base
        .. string.format("\nInferred goal: %s", goal)
        .. string.format("\nUrgency level: %s (%.0f/100)", urgLabel, UrgencySystem.level)
        .. string.format("\nUnexplained events: %s", anomStr ~= "" and anomStr or "none")
        .. string.format("\nActive macros: %d | Behavior clusters: %d",
            (function() local c=0; for _ in pairs(EAT.macros) do c+=1 end; return c end)(),
            #EAT.clusters)
end

-- ============================================================
-- SECTION 38: SELF-ASSESSMENT REPORT
-- ============================================================
--[[
    Periodically generates a plain-text self-assessment of the AI's
    current knowledge state. Useful for the user to understand what
    the AI knows and what it still needs to learn.
    Also used as context when switching from Local AI to LLM mode.
]]

local SelfAssessment = {}

function SelfAssessment.generate()
    local place    = getCurrentPlace()
    local urgLabel = UrgencySystem.getLabel()
    local goal     = GoalEngine.currentGoal

    local macroCount, patternCount = 0, 0
    for _ in pairs(EAT.macros)             do macroCount   += 1 end
    for _ in pairs(EAT.reinforcedPatterns) do patternCount += 1 end

    local anomalies  = AnomalyDetector.getUnpromoted()
    local weakAreas  = {}
    local strongAreas= {}

    if place then
        if place.confidence.movement  < 40 then table.insert(weakAreas,   "movement")
        else                                     table.insert(strongAreas, "movement") end
        if place.confidence.ui        < 40 then table.insert(weakAreas,   "UI interaction")
        else                                     table.insert(strongAreas, "UI interaction") end
        if place.confidence.sequences < 40 then table.insert(weakAreas,   "action sequences")
        else                                     table.insert(strongAreas, "action sequences") end
    end

    local lines = {
        "=== VoidAI Self-Assessment ===",
        "Game: "     .. (place and place.placeName or "Unknown"),
        "Sessions: " .. (place and #place.sessions or 0),
        "Goal: "     .. goal,
        "Urgency: "  .. urgLabel .. string.format(" (%.0f/100)", UrgencySystem.level),
        "",
        "KNOWLEDGE:",
        "  Strong: " .. (#strongAreas > 0 and table.concat(strongAreas, ", ") or "none yet"),
        "  Weak:   " .. (#weakAreas   > 0 and table.concat(weakAreas,   ", ") or "none"),
        "",
        "LEARNED STRUCTURES:",
        "  Macros:   " .. macroCount,
        "  Clusters: " .. #EAT.clusters,
        "  Patterns: " .. patternCount,
        "  Anomalies:" .. #anomalies .. " unresolved",
        "",
        "RECOMMENDATIONS:",
    }

    if #weakAreas > 0 then
        table.insert(lines, "  → Train more: " .. table.concat(weakAreas, ", "))
    end
    if macroCount < 3 then
        table.insert(lines, "  → Play longer to build macro library")
    end
    if #EAT.clusters < 10 then
        table.insert(lines, "  → Explore more areas to build mental map")
    end
    if place and #place.sessions >= 5 and macroCount >= 3 then
        table.insert(lines, "  ✓ AI is ready for LLM-assisted play")
    end

    table.insert(lines, "")
    table.insert(lines, "Generated: " .. tostring(os.time()))

    local report = table.concat(lines, "\n")

    -- Save to file
    local path = BRAIN_FOLDER .. "/assessment_" .. getPlaceId() .. ".txt"
    Exec.writeFile(path, report)

    return report
end

-- Add to chat commands
CHAT_COMMANDS["assess"] = function()
    local report = SelfAssessment.generate()
    -- Show first 3 lines as notification
    local firstLines = report:split("\n")
    local preview = ""
    for i = 1, math.min(3, #firstLines) do
        preview = preview .. firstLines[i] .. " | "
    end
    VoidGUI.notify(preview)
    print(report)
end

-- ============================================================
-- SECTION 39: REPLAY SPEED CONTROL + LOOP MODES
-- ============================================================
--[[
    Gives the user fine control over how the AI replays sessions.
    Modes:
      - Normal:    plays actions at recorded pace
      - Fast:      2x speed
      - Cautious:  0.5x speed, more pauses
      - Loop:      repeats best session forever
      - Once:      plays once then stops
    Also integrates urgency — high urgency overrides to Fast mode.
]]

local ReplayControl = {}
ReplayControl.speed     = 1.0     -- multiplier
ReplayControl.loopMode  = "loop"  -- "loop" | "once"
ReplayControl.paused    = false

local SPEED_PRESETS = {
    Cautious = 0.5,
    Normal   = 1.0,
    Fast     = 2.0,
    Panic    = 4.0,   -- auto-set by urgency
}

function ReplayControl.setSpeed(preset)
    ReplayControl.speed = SPEED_PRESETS[preset] or 1.0
    VoidGUI.notify("Replay speed: " .. preset)
end

function ReplayControl.getInterval()
    local base     = 0.2
    local urgency  = UrgencySystem.level
    -- High urgency overrides to faster replay
    local urgencyMod = 1 - (urgency / 100) * 0.7
    local finalInterval = (base / ReplayControl.speed) * urgencyMod
    return math.max(0.04, finalInterval)
end

-- Patch main heartbeat ghost/lend step to use ReplayControl interval
local replayAccum = 0
RunService.Heartbeat:Connect(function(dt)
    if AIState.mode ~= "ghost" and AIState.mode ~= "lend" then
        replayAccum = 0
        return
    end
    if AIState.isPaused then return end

    replayAccum += dt
    if replayAccum >= ReplayControl.getInterval() then
        replayAccum = 0
        GhostController.stepReplay()
    end
end)

-- ============================================================
-- SECTION 40: MULTI-BRAIN SUPPORT
-- ============================================================
--[[
    Allows saving and loading multiple named brain profiles
    for the same game. Useful if you want a "grind" brain
    and a "exploration" brain for the same experience.
    Named brains are stored as universe_ID_profilename.json
]]

local MultiBrain = {}
MultiBrain.profiles = {}

function MultiBrain.listProfiles()
    local files = Exec.listFiles(BRAIN_FOLDER)
    local profiles = {}
    local prefix   = "universe_" .. getUniverseId()
    for _, f in ipairs(files) do
        if f:find(prefix) then
            -- Extract profile name from filename
            local name = f:match("universe_%d+_?(.-)%.json") or "default"
            table.insert(profiles, {file = f, name = name == "" and "default" or name})
        end
    end
    return profiles
end

function MultiBrain.saveAs(profileName)
    local safeName = profileName:gsub("[^%w%-_]", "")
    if safeName == "" then safeName = "default" end
    local path = BRAIN_FOLDER .. "/universe_" .. getUniverseId() .. "_" .. safeName .. ".json"
    local ok, encoded = pcall(HttpService.JSONEncode, HttpService, Brain)
    if ok then
        Exec.writeFile(path, encoded)
        VoidGUI.notify("Brain saved as: " .. safeName)
    end
end

function MultiBrain.loadProfile(profileName)
    local safeName = profileName:gsub("[^%w%-_]", "")
    local path     = BRAIN_FOLDER .. "/universe_" .. getUniverseId() .. "_" .. safeName .. ".json"
    if not Exec.isFile(path) then
        VoidGUI.notify("Profile not found: " .. safeName)
        return false
    end
    local raw = Exec.readFile(path)
    if not raw then return false end
    local ok, decoded = pcall(HttpService.JSONDecode, HttpService, raw)
    if ok and decoded then
        Brain = decoded
        Brain.baseKnowledge = BASE_KNOWLEDGE
        EAT.loadMacros()
        EAT.loadClusters()
        VoidGUI.notify("Loaded brain: " .. safeName)
        VoidGUI.updateDisplay()
        return true
    end
    return false
end

-- Chat commands for multi-brain
CHAT_COMMANDS["saveas"] = function()
    -- /void saveas profilename
    -- Parse not possible from single command, use notify to guide
    VoidGUI.notify("Type /void saveas_<name> to save a profile")
end

-- Dynamic command for /void saveas_grind, /void saveas_explore etc.
local _origChatHandler = nil
pcall(function()
    LocalPlayer.Chatted:Connect(function(msg)
        local lower = msg:lower()
        local parts = lower:split(" ")
        if parts[1] ~= CHAT_PREFIX then return end
        local cmd = parts[2] or ""
        -- Handle saveas_ prefix
        if cmd:sub(1, 7) == "saveas_" then
            local profileName = cmd:sub(8)
            if profileName and profileName ~= "" then
                MultiBrain.saveAs(profileName)
            end
        elseif cmd:sub(1, 5) == "load_" then
            local profileName = cmd:sub(6)
            if profileName and profileName ~= "" then
                MultiBrain.loadProfile(profileName)
            end
        end
    end)
end)

-- ============================================================
-- SECTION 41: PERFORMANCE PROFILER
-- ============================================================
--[[
    Tracks how long each VoidAI subsystem takes per frame.
    Displayed in stats tab. Helps identify bottlenecks on low-end devices.
    Auto-disables expensive subsystems if they exceed budget.
]]

local Profiler = {}
Profiler.budgetMs  = 2.0    -- max ms per frame allowed for VoidAI
Profiler.times     = {
    observer  = 0,
    eat       = 0,
    atm       = 0,
    threat    = 0,
    urgency   = 0,
    total     = 0,
}
Profiler.warnings  = {}
Profiler.enabled   = true

local function profiledRun(label, fn)
    if not Profiler.enabled then fn(); return end
    local start  = os.clock()
    local ok, err = pcall(fn)
    local elapsed = (os.clock() - start) * 1000  -- ms
    Profiler.times[label] = Profiler.times[label] * 0.8 + elapsed * 0.2  -- EMA

    if elapsed > Profiler.budgetMs then
        table.insert(Profiler.warnings, {
            label   = label,
            ms      = elapsed,
            time    = os.time(),
        })
        -- Cap warnings list
        if #Profiler.warnings > 20 then
            table.remove(Profiler.warnings, 1)
        end
    end

    if not ok then
        warn("[VoidAI:Profiler] Error in " .. label .. ": " .. tostring(err))
    end
end

-- ============================================================
-- SECTION 42: FINAL GUI POLISH — URGENCY + GOAL + PREDICTION
-- ============================================================

task.spawn(function()
    task.wait(1.5)
    if not screenGui then return end
    local panel = screenGui:FindFirstChild("VoidAIPanel")
    if not panel then return end

    local scrollFrames = {}
    for _, child in ipairs(panel:GetChildren()) do
        if child:IsA("ScrollingFrame") then
            table.insert(scrollFrames, child)
        end
    end

    local mainScroll  = scrollFrames[1]
    local statsScroll = scrollFrames[2]

    -- Add urgency + goal bar to main tab
    if mainScroll then
        local urgencyLabel = makeLabel(mainScroll,
            "Urgency: CALM | Goal: explore",
            UDim2.new(1,-12,0,14),
            UDim2.new(0,6,0,422),
            9, COLORS.success)

        task.spawn(function()
            while true do
                task.wait(1)
                if not urgencyLabel or not urgencyLabel.Parent then break end
                local label, color = UrgencySystem.getLabel()
                local goal         = GoalEngine.currentGoal
                urgencyLabel.Text       = string.format(
                    "Urgency: %s (%.0f) | Goal: %s",
                    label, UrgencySystem.level, goal)
                urgencyLabel.TextColor3 = color
            end
        end)

        mainScroll.CanvasSize = UDim2.new(0,0,0,700)
    end

    -- Add profiler readout to stats tab
    if statsScroll then
        makeLabel(statsScroll,
            "⚡ Subsystem Timing (ms avg)",
            UDim2.new(1,-12,0,14),
            UDim2.new(0,6,0,440),
            11, COLORS.accent).Font = Enum.Font.GothamBold

        local profLabels = {}
        local subsystems = {"observer","eat","atm","threat","urgency","total"}
        for i, sys in ipairs(subsystems) do
            local lbl = makeLabel(statsScroll,
                sys .. ": 0.0ms",
                UDim2.new(1,-12,0,13),
                UDim2.new(0,6,0,456 + (i-1)*14),
                9, COLORS.textDim)
            profLabels[sys] = lbl
        end

        task.spawn(function()
            while true do
                task.wait(1)
                for sys, lbl in pairs(profLabels) do
                    if lbl and lbl.Parent then
                        local ms    = Profiler.times[sys] or 0
                        local color = ms > 1.5 and COLORS.danger
                               or ms > 0.8 and COLORS.warning
                               or COLORS.success
                        lbl.Text       = string.format("%s: %.2fms", sys, ms)
                        lbl.TextColor3 = color
                    end
                end
            end
        end)

        statsScroll.CanvasSize = UDim2.new(0,0,0,580)
    end

    -- Add self-assessment button to stats tab
    if statsScroll then
        local assessBtn = makeButton(statsScroll,
            "📋 Generate Self-Assessment",
            UDim2.new(1,-12,0,28),
            UDim2.new(0,6,0,548),
            COLORS.panel)
        assessBtn.MouseButton1Click:Connect(function()
            local report = SelfAssessment.generate()
            local lines  = report:split("\n")
            VoidGUI.notify(lines[1] .. " — see console")
            print(report)
        end)
        statsScroll.CanvasSize = UDim2.new(0,0,0,620)
    end

    -- Replay speed buttons in settings tab
    local settingsScroll = scrollFrames[4]
    if settingsScroll then
        makeLabel(settingsScroll,
            "Replay Speed",
            UDim2.new(1,-12,0,14),
            UDim2.new(0,6,0,640),
            11, COLORS.textDim)

        local speedPresets = {"Cautious","Normal","Fast"}
        for i, preset in ipairs(speedPresets) do
            local spBtn = makeButton(settingsScroll,
                preset,
                UDim2.new(1/3,-5,0,24),
                UDim2.new((i-1)/3, 3, 0, 658),
                ReplayControl.speed == SPEED_PRESETS[preset] and COLORS.accent or COLORS.panel)
            spBtn.TextSize = 10
            spBtn.MouseButton1Click:Connect(function()
                ReplayControl.setSpeed(preset)
            end)
        end

        -- Loop mode toggle
        local loopBtn = makeButton(settingsScroll,
            "Loop: " .. ReplayControl.loopMode,
            UDim2.new(1,-12,0,26),
            UDim2.new(0,6,0,688),
            COLORS.panel)
        loopBtn.TextSize = 10
        loopBtn.MouseButton1Click:Connect(function()
            ReplayControl.loopMode = ReplayControl.loopMode == "loop" and "once" or "loop"
            loopBtn.Text = "Loop: " .. ReplayControl.loopMode
        end)

        settingsScroll.CanvasSize = UDim2.new(0,0,0,740)
    end
end)

-- ============================================================
-- SECTION 43: LOOP MODE ENFORCEMENT
-- ============================================================
-- Patch GhostController to respect loopMode = "once"

local _baseStepForLoop = GhostController.stepReplay
local hasPlayedOnce    = false

function GhostController.stepReplayFinal()
    if ReplayControl.loopMode == "once" then
        if not GhostController.replaySession then return end
        local actions = GhostController.replaySession.actions
        if GhostController.replayIndex > #(actions or {}) then
            if not hasPlayedOnce then
                hasPlayedOnce = true
                GhostController.reclaim()
                VoidGUI.notify("Replay complete (once mode)")
            end
            return
        end
    end
    hasPlayedOnce = false
    _baseStepForLoop()
end

GhostController.stepReplay = GhostController.stepReplayFinal

-- ============================================================
-- SECTION 44: COMPLETE CHAT COMMAND REFERENCE
-- ============================================================
-- Add remaining missing commands and a full help output

CHAT_COMMANDS["help"] = function()
    local cmds = {
        "--- VoidAI Commands (/void <cmd>) ---",
        "learn / stop     — toggle observer mode",
        "ghost / lend     — toggle ghost / lend mode",
        "llm              — toggle LLM mode",
        "eat              — toggle Extended Adaptive Thinking",
        "atm              — toggle Adaptive Thinking (AFK mode)",
        "afk              — force AFK trigger",
        "reclaim          — take back control",
        "pause            — pause AI",
        "safe/danger/exit/item — tag current zone",
        "save             — save brain now",
        "assess           — generate self-assessment",
        "predict          — show next action prediction",
        "forget           — apply forgetting curve",
        "macros           — list learned macros",
        "status           — show current AI status",
        "stealth          — toggle stealth mode",
        "saveas_<name>    — save brain profile",
        "load_<name>      — load brain profile",
        "help             — show this list",
    }
    for _, line in ipairs(cmds) do print("[VoidAI] " .. line) end
    VoidGUI.notify("Full command list printed to console")
end

CHAT_COMMANDS["speed"] = function()
    local speeds = {"Cautious (0.5x)","Normal (1x)","Fast (2x)"}
    VoidGUI.notify("Speeds: " .. table.concat(speeds, " | ") .. " — use /void speed_<name>")
end

-- Dynamic speed command
pcall(function()
    LocalPlayer.Chatted:Connect(function(msg)
        local lower = msg:lower()
        local parts = lower:split(" ")
        if parts[1] ~= CHAT_PREFIX then return end
        local cmd = parts[2] or ""
        if cmd:sub(1,6) == "speed_" then
            local preset = cmd:sub(7)
            local map    = {cautious="Cautious", normal="Normal", fast="Fast"}
            if map[preset] then
                ReplayControl.setSpeed(map[preset])
            end
        end
    end)
end)

-- ============================================================
-- SECTION 45: STARTUP SUMMARY
-- ============================================================

local function printStartupSummary()
    local place = getCurrentPlace()
    local mc, patc = 0, 0
    for _ in pairs(EAT.macros)             do mc   += 1 end
    for _ in pairs(EAT.reinforcedPatterns) do patc += 1 end

    local function pad(s, n)
        s = tostring(s):sub(1, n)
        while #s < n do s = s .. " " end
        return s
    end

    print("╔════════════════════════════════════╗")
    print("║       VoidAI v1.0  —  Ready        ║")
    print("╠════════════════════════════════════╣")
    print("║ Platform : " .. pad(isMobile and "Mobile" or "PC",         25) .. "║")
    print("║ Universe : " .. pad(getUniverseId(),                        25) .. "║")
    print("║ Place    : " .. pad(getPlaceId(),                           25) .. "║")
    if place then
        print("║ Game     : " .. pad((place.placeName or "Unknown"),     25) .. "║")
        print("║ Sessions : " .. pad(#place.sessions,                   25) .. "║")
        print("║ Conf Mov : " .. pad(place.confidence.movement  .. "%", 25) .. "║")
        print("║ Conf UI  : " .. pad(place.confidence.ui        .. "%", 25) .. "║")
        print("║ Conf Seq : " .. pad(place.confidence.sequences .. "%", 25) .. "║")
    end
    print("║ Macros   : " .. pad(mc,                                     25) .. "║")
    print("║ Clusters : " .. pad(#EAT.clusters,                          25) .. "║")
    print("║ Patterns : " .. pad(patc,                                   25) .. "║")
    print("╠════════════════════════════════════╣")
    if isPC then
        print("║ Keybinds: G=Learn  H=Lend          ║")
        print("║           J=GUI    F=Reclaim        ║")
    else
        print("║ Mobile: Tap floating buttons        ║")
        print("║         ✋ = Reclaim control         ║")
    end
    print("╠════════════════════════════════════╣")
    print("║ Chat commands: /void help           ║")
    print("╚════════════════════════════════════╝")
end

-- ============================================================
-- SECTION 46: CONSOLIDATED FINAL HEARTBEAT
-- ============================================================
-- Single authoritative heartbeat — replaces fragmented ones above.
-- All per-frame logic is profiled and rate-limited here.

local _fb_learn   = 0
local _fb_threat  = 0
local _fb_urgency = 0
local _fb_save    = 0
local _fb_goal    = 0
local _fb_afk     = 0
local _fb_eat     = 0
local _fb_atm     = 0

RunService.Heartbeat:Connect(function(dt)
    updateFPS(dt)
    AIState.tickCounter += 1
    updateSchedule(dt)

    -- AFK check every 5s
    _fb_afk += dt
    if _fb_afk >= 5 then
        _fb_afk = 0
        if not AdaptiveThinking.manualOverride then
            local idle = os.time() - lastInputTime
            if idle >= AdaptiveThinking.afkThreshold
                and not AdaptiveThinking.active
                and (AIState.mode == "idle" or AIState.mode == "observing") then
                AdaptiveThinking.start("AFK auto (" .. idle .. "s)")
            end
        end
    end

    -- Threat detection every 0.5s (always runs regardless of FPS)
    _fb_threat += dt
    if _fb_threat >= 0.5 then
        _fb_threat = 0
        local ok2 = pcall(function()
            local isThreat, tName = ThreatDetector.scan()
            if isThreat and not AIState.threatDetected then
                ThreatDetector.respondToThreat(tName)
            end
            if AIState.threatCooldown > 0 then
                AIState.threatCooldown -= 0.5
                if AIState.threatCooldown <= 0 then
                    AIState.threatDetected = false
                end
            end
        end)
        if not ok2 then end -- silent fail on threat scan errors
    end

    -- Observer learning (adaptive cooldown)
    _fb_learn += dt
    if _fb_learn >= LearnCooldown then
        _fb_learn = 0
        if AIState.isLearning and AIState.currentSession then
            pcall(function()
                local char = LocalPlayer.Character
                if not char then return end
                local hrp = char:FindFirstChild("HumanoidRootPart")
                if not hrp then return end
                Observer.recordMovement(hrp.Position)
                -- Feed EAT if active
                if EAT.active then
                    EAT.pushChunk("move")
                    EAT.reinforcePattern(EAT.getWorkingMemoryKey(), "self")
                    EAT.addToCluster(hrp.Position, "visited")
                end
                -- Anomaly check
                if #AIState.currentSession.actions > 0 then
                    local last = AIState.currentSession.actions[#AIState.currentSession.actions]
                    AnomalyDetector.check(last.type or "unknown", GoalEngine.currentGoal)
                end
            end)
        end
    end

    -- EAT sampling (adaptive per FPS + player count)
    if EAT.active then
        _fb_eat += dt
        local pcount = math.max(1, #Players:GetPlayers() - 1)
        local eatRate = (FPS > 50 and pcount <= 5) and 0.2
                     or (FPS > 30)                 and 0.35
                     or                                0.6
        if _fb_eat >= eatRate then
            _fb_eat = 0
            pcall(EAT.observeSelf)
            pcall(EAT.observeOthers)
        end
    end

    -- ATM sampling
    if AdaptiveThinking.active then
        _fb_atm += dt
        if _fb_atm >= AdaptiveThinking.sampleRate then
            _fb_atm = 0
            pcall(AdaptiveThinking.samplePlayers)
        end
    end

    -- Urgency update every 1s
    _fb_urgency += dt
    if _fb_urgency >= 1 then
        _fb_urgency = 0
        pcall(UrgencySystem.update)
    end

    -- Goal inference + prediction every 3s
    _fb_goal += dt
    if _fb_goal >= 3 then
        _fb_goal = 0
        pcall(GoalEngine.infer)
        if EAT.active then
            pcall(EAT.predictNextAction)
        end
    end

    -- LLM step
    if AIState.mode == "llm" and not AIState.isPaused then
        AIState.llmCooldown = (AIState.llmCooldown or 0) + dt
        if AIState.llmCooldown >= LLMController.callCooldown then
            AIState.llmCooldown = 0
            pcall(LLMController.callLLM)
        end
    end

    -- Batch save + forgetting curve + sequence analysis every 30s
    _fb_save += dt
    if _fb_save >= 30 then
        _fb_save = 0
        if AIState.isLearning or EAT.active or AdaptiveThinking.active then
            pcall(EAT.saveMacros)
            pcall(EAT.saveClusters)
            pcall(saveBrain)
        end
        -- Every 5 minutes: forgetting curve + sequence analysis
        if AIState.tickCounter % 10 == 0 then
            pcall(EAT.applyForgettingCurve)
            pcall(SequenceDetector.analyze)
        end
    end
end)

-- ============================================================
-- SECTION 47: BRAIN VERSION MIGRATION
-- ============================================================

local CURRENT_VERSION = "1.0"

local function migrateBrain()
    if not Brain then return end
    if Brain.version == CURRENT_VERSION then return end

    Brain.version = CURRENT_VERSION
    for _, place in pairs(Brain.places or {}) do
        if not place.macros        then place.macros        = {} end
        if not place.clusters      then place.clusters      = {} end
        if not place.topUIButtons  then place.topUIButtons  = {} end
        if not place.totalActions  then place.totalActions  = 0  end
        if not place.totalPlaytime then place.totalPlaytime = 0  end
        if not place.confidence    then
            place.confidence = { movement=0, ui=0, sequences=0 }
        end
        if not place.mentalMap then
            place.mentalMap = {
                landmarks={}, safeZones={}, dangerZones={},
                itemSpawns={}, exits={},
            }
        end
        for _, session in ipairs(place.sessions or {}) do
            if not session.type       then session.type      = "self"      end
            if not session.priority   then session.priority  = 1           end
            if not session.starred    then session.starred   = false       end
            if not session.startTime  then session.startTime = session.id  end
            if not session.movements  then session.movements = {}          end
            if not session.uiClicks   then session.uiClicks  = {}          end
            if not session.sequences  then session.sequences = {}          end
            if not session.actions    then session.actions   = {}          end
        end
    end
    saveBrain()
    print("[VoidAI] Brain migrated to v" .. CURRENT_VERSION)
end

-- ============================================================
-- SECTION 48: MOBILE FINAL WIRING
-- ============================================================

if isMobile then
    task.spawn(function()
        task.wait(3)
        local function hookThumbstick()
            local pgui = LocalPlayer:FindFirstChild("PlayerGui")
            if not pgui then return end
            for _, gui in ipairs(pgui:GetChildren()) do
                local ts = gui:FindFirstChild("DynamicThumbstick", true)
                       or gui:FindFirstChild("Thumbstick", true)
                if ts then
                    RunService.Heartbeat:Connect(function()
                        if not AIState.isLearning then return end
                        if AIState.tickCounter % 3 ~= 0 then return end
                        local char = LocalPlayer.Character
                        if not char then return end
                        local hrp = char:FindFirstChild("HumanoidRootPart")
                        if hrp then Observer.recordMovement(hrp.Position) end
                    end)
                    return true
                end
            end
            return false
        end

        if not hookThumbstick() then
            PlayerGui.ChildAdded:Connect(function()
                task.wait(1)
                hookThumbstick()
            end)
        end
    end)
end

-- ============================================================
-- ENTRY POINT
-- ============================================================

local ok, err = pcall(function()
    -- Core setup
    Exec.makeFolder(BRAIN_FOLDER)
    loadConfig()

    -- Apply config to live state
    if Config.stealthMode   then AIState.isStealth = true                  end
    if Config.activePersona then AIState.persona   = Config.activePersona  end
    if Config.keybinds then
        for action, keyName in pairs(Config.keybinds) do
            if KEY_MAP[keyName] then
                Keybinds.binds[action] = KEY_MAP[keyName]
            end
        end
    end

    -- Load + migrate brain
    loadBrain()
    migrateBrain()

    -- Build GUI
    VoidGUI.build()
    if AIState.isStealth then
        if mainFrame  then mainFrame.Visible  = false end
        if miniButton then miniButton.Visible = true  end
    end

    -- Deferred post-init (let game fully load first)
    task.spawn(function()
        task.wait(2)

        -- Load EAT persistent data
        EAT.loadMacros()
        EAT.loadClusters()

        -- Startup analysis passes
        EAT.applyForgettingCurve()
        SequenceDetector.analyze()
        GoalEngine.infer()

        -- Build threat HUD
        buildThreatHUD()

        -- Horror game detection + safe object pre-tagging
        detectAndConfigureHorror()

        -- Live display update loop
        task.spawn(function()
            while true do
                task.wait(1)
                VoidGUI.updateDisplay()
            end
        end)

        -- Welcome notification
        if not AIState.isStealth then
            local place     = getCurrentPlace()
            local isNewBrain= (not place) or (#place.sessions == 0)
            if isNewBrain then
                VoidGUI.notify("VoidAI ready — press G (or tap 🧠) to start learning!")
            else
                local mc = 0
                for _ in pairs(EAT.macros) do mc += 1 end
                VoidGUI.notify(string.format(
                    "VoidAI ready — %d sessions | %d macros | %d clusters",
                    place and #place.sessions or 0, mc, #EAT.clusters))
            end
        end

        -- Print full console summary
        printStartupSummary()
    end)
end)

if not ok then
    warn("[VoidAI] Critical init error: " .. tostring(err))
    pcall(function()
        Exec.makeFolder(BRAIN_FOLDER)
        loadBrain()
        VoidGUI.build()
        VoidGUI.notify("⚠ VoidAI: init error — check console")
        print("[VoidAI] Recovery mode. Error was: " .. tostring(err))
    end)
end

-- ============================================================
-- END OF VOIDAI v1.0
-- ============================================================
--[[
    SYSTEM INDEX:
    01  Services + platform detection
    02  Executor compatibility (Synapse/KRNL/Fluxus/Arceus/Delta/Hydrogen/Codex)
    03  Base knowledge (embedded WASD + mobile defaults + horror keywords)
    04  Brain file system (per-universe JSON, per-place entries, cumulative)
    05  Config system (persisted settings)
    06  Adaptive learning cooldown + FPS monitor
    07  AI state machine
    08  Observer (movement, UI clicks, jump, sprint — event-driven)
    09  Threat detection + survival response
    10  Ghost controller (client-side AI playback)
    11  Lend controller (AI takes full input control)
    12  LLM controller (Claude / ChatGPT / Gemini with brain context)
    13  Place transition continuity (survives teleports in same universe)
    14  UI click hooks (event-driven, zero polling cost)
    15  Keybind system (PC, fully rebindable via GUI)
    16  Schedule mode (auto start/stop AI at set times)
    17  GUI system (4 tabs, minimize to draggable floating button)
    18  Threat HUD (pulsing indicator)
    19  Sequence detector (cross-session pattern mining)
    20  Smart pattern-aware replay
    21  Jump + sprint event tracking
    22  Other player passive observer
    23  Confidence color coding (green/yellow/red bars)
    24  Low confidence warnings before ghost/lend
    25  Chat command system (/void <cmd>) — 20+ commands
    26  Mental map visualizer (top-down dot map in Stats tab)
    27  Brain export to readable .txt file
    28  Horror game auto-detect + safe object pre-tagging
    29  Schedule countdown notifications
    30  Safety hooks (BindToClose, CharacterRemoving, respawn)
    31  Adaptive Thinking Mode (ATM) — AFK multi-player observer
    32  Extended Adaptive Thinking (EAT) — dual-source live learning
    33  Pattern reinforcement (dual-source weight doubling)
    34  Attention focus (per-player activity scoring)
    35  Action chunking / macro system (10 built-in macro labels)
    36  Forgetting curve (logarithmic time-decay confidence)
    37  Next-action prediction engine (scored candidates)
    38  Working memory buffer (short-term vs long-term split)
    39  Behavior clustering (path generalization via centroid merge)
    40  Anomaly detection + pattern promotion
    41  Urgency / emotion state (0-100, CALM/MED/HIGH/PANIC)
    42  Goal inference engine (explore/combat/interact/escape/etc)
    43  Self-assessment report generator
    44  Replay speed control (Cautious/Normal/Fast) + loop modes
    45  Multi-brain profile support (saveas_/load_ commands)
    46  Performance profiler (per-subsystem ms EMA tracking)
    47  Consolidated heartbeat (single connection, all systems)
    48  Mobile thumbstick wiring
    49  Brain version migration
]]

Features & Information

There is now a self learning ai which can observe your gameplay (and other players) and You can let it learn By spawning in a clone its a ai inside of roblox No need for api key (tho if you want you can link claude openai or gemini api keys for a actual Ai)
#ai#SelfLearning#Machine#Automatic#auto

Community Comments (1)

tazgamerz7867tazgamerz7867May 10, 2026

if you try it out thanks a lot!

More Scripts for Universal Scripts