--[[
=====================================================================
Build The Great Wall - autofarm hub (Airflow UI)
=====================================================================
1) Auto farm: quarry (Fill) -> wall (Place)
2) Auto upgrade purchase at the shrine
3) Auto Bench (Strength)
4) Auto Treadmill (Speed)
5) Hot Spring stay (only while the springs are open)
QoL:
* Task priorities (OrderList + "Priority" mode)
* Timed rotation: 30s farm -> 30s run -> 30s gym -> repeat
* Upgrade autobuy between tasks
Run: paste into Potassium -> Execute. Toggle the window: RightControl.
=====================================================================
--]]
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CollectionService = game:GetService("CollectionService")
local player = Players.LocalPlayer
---------------------------------------------------------------------
-- UI library
---------------------------------------------------------------------
local LIBRARY_URL = "https://raw.githubusercontent.com/bobloscript/Robly_Airflow/main/Source.luau"
local Airflow
do
local ok, result = pcall(function()
return loadstring(game:HttpGet(LIBRARY_URL))()
end)
if not ok or type(result) ~= "table" then
error("[GreatWallHub] failed to load Airflow UI: " .. tostring(result))
end
Airflow = result
end
---------------------------------------------------------------------
-- Game objects
---------------------------------------------------------------------
local GreatWall = ReplicatedStorage:WaitForChild("GreatWall", 60)
local Remotes = GreatWall:WaitForChild("Remotes", 60)
local FillRemote = Remotes:WaitForChild("Fill", 60)
local PlaceRemote = Remotes:WaitForChild("Place", 60)
local UpgradeRemote = Remotes:WaitForChild("Upgrade", 60)
local Config = require(GreatWall:WaitForChild("Config"))
local UPGRADE_LIST = (Config.Upgrades and Config.Upgrades.List) or {}
local UPGRADE_BY_ID = {}
for _, info in ipairs(UPGRADE_LIST) do
UPGRADE_BY_ID[info.Id] = info
end
---------------------------------------------------------------------
-- Utilities
---------------------------------------------------------------------
local function attribute(name, default)
local value = player:GetAttribute(name)
if value == nil then
return default
end
return value
end
local function coins()
return tonumber(attribute("Coins", 0)) or 0
end
local function blocksInBag()
return tonumber(attribute("Blocks", 0)) or 0
end
local function capacity()
return tonumber(attribute("Capacity", Config.CarryCapacity or 25)) or 25
end
local function serverNow()
return workspace:GetServerTimeNow()
end
local function character()
return player.Character
end
local function humanoid()
local char = character()
return char and char:FindFirstChildOfClass("Humanoid")
end
local function rootPart()
local char = character()
return char and char:FindFirstChild("HumanoidRootPart")
end
local function formatNumber(value)
local text = tostring(math.floor(tonumber(value) or 0))
return text:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
end
local function logger(...)
local parts = {}
for index = 1, select("#", ...) do
table.insert(parts, tostring(select(index, ...)))
end
print("[GreatWallHub] " .. table.concat(parts, " "))
end
---------------------------------------------------------------------
-- State (declared before every function that reads it)
---------------------------------------------------------------------
local State = {
running = true,
generation = 0,
taskUntil = 0,
taskDuration = 0,
task = "Idle",
sequence = math.random(100000, 900000),
unsticking = false,
sectionCooldown = {},
sectionRejects = {},
ghostFailures = {},
rateSamples = {},
manualUntil = 0,
options = {
autoFarm = false,
section = "Auto",
placeDelay = 0.06,
autoDelay = true,
autoUnstuck = true,
autoBench = false,
autoTreadmill = false,
autoSprings = false,
springsPreempt = true,
autoUpgrades = false,
upgradeStrategy = "In order",
upgradeOrder = { "Quarry", "Swift", "Mason", "Reach", "Golden", "Fortune", "Combo", "Trowel", "Blitz" },
autopilot = true,
mode = "Rotation",
order = { "Farm", "Treadmill", "Bench", "Springs" },
farmSeconds = 30,
runSeconds = 30,
gymSeconds = 30,
springsSeconds = 20,
},
stats = {
placed = 0,
coinsEarned = 0,
stages = 0,
upgrades = 0,
startedAt = os.clock(),
},
debug = {
fires = 0,
accepted = 0,
rejected = 0,
moves = 0,
moveFails = 0,
loops = 0,
timeSearch = 0,
timeAnchor = 0,
timeMove = 0,
},
}
local TASK_LABELS = {
Farm = "Farm: quarry + wall",
Treadmill = "Treadmill",
Bench = "Bench press",
Springs = "Hot spring",
Upgrades = "Upgrades",
Idle = "Idle",
}
---------------------------------------------------------------------
-- Movement
---------------------------------------------------------------------
local function quarrySpot()
local quarry = workspace:FindFirstChild("Quarry")
local pit = quarry and quarry:FindFirstChild("Pit")
local floor = pit and pit:FindFirstChild("Floor")
if floor then
return floor.Position + Vector3.new(0, 10, 0)
end
return Vector3.new(0, 2, -362)
end
local function standUp()
local hum = humanoid()
if hum and hum.Sit then
hum.Sit = false
pcall(function()
hum:ChangeState(Enum.HumanoidStateType.GettingUp)
end)
end
end
local function teleport(position)
local hrp = rootPart()
if not hrp then
return false
end
standUp()
hrp.CFrame = CFrame.new(position)
task.wait(0.12)
local current = rootPart()
if current and (current.Position - position).Magnitude <= 25 then
return true
end
-- the write did not stick: the character is most likely pinned (e.g. shrine pose)
if State.unsticking then
return false
end
if unstick() then
local fresh = rootPart()
if fresh then
fresh.CFrame = CFrame.new(position)
task.wait(0.15)
return true
end
end
return false
end
--- The character is pinned in a pose/geometry: a respawn clears the lock.
function unstick()
if State.unsticking then
return false
end
State.unsticking = true
local hum = humanoid()
if hum then
logger("resetting character (pinned)")
hum.Health = 0
task.wait(0.6)
local spot = quarrySpot() + Vector3.new(0, 10, 0)
for _ = 1, 20 do
local hrp = rootPart()
if hrp then
hrp.CFrame = CFrame.new(spot)
end
task.wait(0.1)
end
end
State.unsticking = false
return true
end
--- Check whether CFrame writes still apply (i.e. we are not pinned).
local function isPinned()
local hrp = rootPart()
if not hrp then
return false
end
local probe = hrp.Position + Vector3.new(0, 8, 0)
hrp.CFrame = CFrame.new(probe)
task.wait(0.15)
local current = rootPart()
if not current then
return false
end
return (current.Position - probe).Magnitude > 5
end
---------------------------------------------------------------------
-- Map points
---------------------------------------------------------------------
local PLOT_TIERS = {
{ Name = "Free", Multiplier = 1 },
{ Name = "2x", Multiplier = 2 },
{ Name = "5x", Multiplier = 5 },
{ Name = "10x", Multiplier = 10 },
{ Name = "25x", Multiplier = 25 },
{ Name = "50x", Multiplier = 50 },
{ Name = "75x", Multiplier = 75 },
{ Name = "100x", Multiplier = 100 },
{ Name = "500x", Multiplier = 500 },
}
local function bestGymPlot()
local area = workspace:FindFirstChild("TrainingArea")
local plots = area and area:FindFirstChild("Plots")
if not plots then
return nil, 1
end
local best, bestMultiplier = nil, 0
for _, tier in ipairs(PLOT_TIERS) do
local model = plots:FindFirstChild("Plot_" .. tier.Name)
if model then
local unlocked = (tier.Name == "Free") or attribute("Gym_" .. tier.Name, false) == true
if unlocked and tier.Multiplier > bestMultiplier then
best, bestMultiplier = model, tier.Multiplier
end
end
end
return best, bestMultiplier
end
local function benchTarget()
local plot = bestGymPlot()
if not plot then
return nil, nil
end
local bench = plot:FindFirstChild("BenchPress")
local prompt = bench and bench:FindFirstChild("BenchPrompt", true)
if not prompt or not prompt:IsA("ProximityPrompt") then
return nil, nil
end
local seat = prompt.Parent and prompt.Parent.Parent
if not seat or not seat:IsA("BasePart") then
return nil, nil
end
return seat, prompt
end
local function treadmillSpot()
local plot = bestGymPlot()
if not plot then
return nil
end
local treadmill = plot:FindFirstChild("Treadmill")
if not treadmill then
return nil
end
local best
for _, part in ipairs(treadmill:GetDescendants()) do
if part:IsA("BasePart") and part.Name == "Belt" then
if not best or (part.Size.X * part.Size.Z) > (best.Size.X * best.Size.Z) then
best = part
end
end
end
return best
end
local function springsOpen()
return (GreatWall:GetAttribute("WallBoostEnd") or 0) > serverNow()
end
local function springsTimeLeft()
return math.max(0, (GreatWall:GetAttribute("WallBoostEnd") or 0) - serverNow())
end
local function springsSpot()
local model = workspace:FindFirstChild("HotSprings")
local zone = model and model:FindFirstChild("RelaxZone")
if zone and zone:IsA("BasePart") then
return zone.Position + Vector3.new(0, 2, 0)
end
return nil
end
local function shrineSpot()
local shrine = workspace:FindFirstChild("UpgradeShrine")
if not shrine then
return nil
end
local zone = shrine:FindFirstChild("Zone")
if zone and zone:IsA("BasePart") then
-- standing closer than ~9 studs locks the character into the praying pose,
-- so we keep 10 studs away (the purchase radius is 11)
return zone.Position + Vector3.new(0, -1, 10)
end
local altar = shrine:FindFirstChild("Altar")
if altar then
local part = altar:FindFirstChildWhichIsA("BasePart")
if part then
return part.Position + Vector3.new(0, 2, 12)
end
end
return nil
end
local function shrineZone()
local shrine = workspace:FindFirstChild("UpgradeShrine")
local zone = shrine and shrine:FindFirstChild("Zone")
if zone and zone:IsA("BasePart") then
return zone
end
return nil
end
---------------------------------------------------------------------
-- Upgrades
---------------------------------------------------------------------
local function upgradeLevel(id)
return tonumber(attribute("Up_" .. id, 0)) or 0
end
local function upgradeCost(id)
local info = UPGRADE_BY_ID[id]
if not info then
return nil
end
local level = upgradeLevel(id)
if level >= (info.Max or 0) then
return nil
end
local costs = info.Costs or {}
return costs[level + 1]
end
local function upgradeUnlocked(id)
local info = UPGRADE_BY_ID[id]
if not info then
return false
end
local required = info.RequiresWalls
if required and (tonumber(attribute("Walls", 0)) or 0) < required then
return false
end
return true
end
local function upgradePlan()
local best
for _, id in ipairs(State.options.upgradeOrder) do
if upgradeUnlocked(id) then
local cost = upgradeCost(id)
if cost then
if State.options.upgradeStrategy == "Cheapest first" then
if not best or cost < best.cost then
best = { id = id, cost = cost }
end
else
return { id = id, cost = cost }
end
end
end
end
return best
end
local function hasUpgradeOpportunity()
if not State.options.autoUpgrades then
return false
end
local plan = upgradePlan()
if not plan then
return false
end
return coins() >= plan.cost
end
---------------------------------------------------------------------
-- Wall: slots and placement
---------------------------------------------------------------------
local ghostCache = {
list = {},
fetchedAt = 0,
}
local function refreshGhosts()
if os.clock() - ghostCache.fetchedAt < 1.0 then
return ghostCache.list
end
ghostCache.fetchedAt = os.clock()
local list = {}
for _, part in ipairs(CollectionService:GetTagged("WallGhost")) do
if part:IsA("BasePart") and part.Parent and part.Name == "Ghost" and part:GetAttribute("S") then
table.insert(list, part)
end
end
table.sort(list, function(a, b)
local layerA, layerB = a:GetAttribute("L") or 0, b:GetAttribute("L") or 0
if layerA ~= layerB then
return layerA < layerB
end
local sectionA, sectionB = a:GetAttribute("S") or 0, b:GetAttribute("S") or 0
if sectionA ~= sectionB then
return sectionA < sectionB
end
return (a:GetAttribute("I") or 0) < (b:GetAttribute("I") or 0)
end)
ghostCache.list = list
return list
end
local function ghostTotal()
return #refreshGhosts()
end
--- Distance the server lets us build from (Reach upgrade raises PlaceRange).
local function currentReach()
return math.max(18, (tonumber(attribute("PlaceRange", 28)) or 28) - 4)
end
--- Delay between two Place calls. The game's own cooldown is the fastest safe value:
--- PlaceCooldown is a player attribute that already includes Swift/Blitz upgrades.
local function currentDelay()
if State.options.autoDelay then
local cooldown = tonumber(attribute("PlaceCooldown", 0.1)) or 0.1
return math.clamp(cooldown, 0.03, 0.3)
end
return State.options.placeDelay
end
local function ghostUsable(ghost, now)
local section = tostring(ghost:GetAttribute("S"))
local cooldown = State.sectionCooldown[section]
if cooldown and now < cooldown then
return false
end
local stamp = State.ghostFailures[ghost]
if stamp and now - stamp < 20 then
return false
end
if State.options.section ~= "Auto" and section ~= tostring(State.options.section) then
return false
end
return true
end
local function ghostInReach(position, reach)
local now = os.clock()
for _, ghost in ipairs(refreshGhosts()) do
if ghost.Parent and ghost.Name == "Ghost" and (ghost.Position - position).Magnitude <= reach then
if ghostUsable(ghost, now) then
return ghost
end
end
end
return nil
end
--- Pick a slot with the most neighbours: standing next to it gives dozens of
--- placeable slots at once, so we teleport once per cluster instead of per block.
local function anchorGhost(reach)
local now = os.clock()
local candidates = {}
for _, ghost in ipairs(refreshGhosts()) do
if ghost.Parent and ghost.Name == "Ghost" and ghostUsable(ghost, now) then
table.insert(candidates, ghost)
if #candidates >= 260 then
break
end
end
end
if #candidates == 0 then
return nil, 0
end
local radius = reach * 0.8
local best, bestCount = nil, 0
for _, ghost in ipairs(candidates) do
local count = 0
for _, other in ipairs(candidates) do
if (other.Position - ghost.Position).Magnitude <= radius then
count = count + 1
end
end
if count > bestCount then
best, bestCount = ghost, count
end
end
return best, bestCount
end
--- Move the character without the pin machinery: used per cluster, where a
--- failed hop must not trigger a respawn.
local function placeCharacterAt(position)
local hrp = rootPart()
if not hrp then
return false
end
standUp()
hrp.CFrame = CFrame.new(position)
task.wait(0.1)
local current = rootPart()
return current ~= nil and (current.Position - position).Magnitude <= 30
end
--- Stand within reach of the anchor slot, preferring the front side of the wall.
local function moveToCluster(anchor, reach)
local distance = math.min(reach * 0.5, 18)
local offsets = {
Vector3.new(0, 4, distance),
Vector3.new(0, 4, -distance),
Vector3.new(distance, 4, 0),
Vector3.new(-distance, 4, 0),
}
for _, offset in ipairs(offsets) do
if placeCharacterAt(anchor.Position + offset) then
return true
end
end
return false
end
local function firePlace(ghost)
State.sequence = State.sequence + 1
PlaceRemote:FireServer(ghost:GetAttribute("S"), ghost:GetAttribute("L"), ghost:GetAttribute("I"), State.sequence)
end
local function fillBag()
local spot = quarrySpot()
if not teleport(spot + Vector3.new(0, 6, 0)) then
return 0
end
task.wait(0.4)
local started = os.clock()
local stagnant = 0
while os.clock() - started < 12 do
if blocksInBag() >= capacity() then
break
end
local before = blocksInBag()
FillRemote:FireServer(14, false)
task.wait(0.07)
if blocksInBag() <= before then
stagnant = stagnant + 1
if stagnant > 14 then
teleport(spot)
task.wait(0.4)
stagnant = 0
end
else
stagnant = 0
end
end
return blocksInBag()
end
---------------------------------------------------------------------
-- Scheduler primitives
---------------------------------------------------------------------
local function beginTask(name, seconds)
State.generation = State.generation + 1
State.task = name
State.taskDuration = seconds
State.taskUntil = os.clock() + seconds
return State.generation
end
local function isActive(generation)
return State.running and State.generation == generation and os.clock() < State.taskUntil
end
local function stopTask()
State.generation = State.generation + 1
State.task = "Idle"
end
local function taskEnabled(name)
if name == "Farm" then
return State.options.autoFarm
elseif name == "Bench" then
return State.options.autoBench
elseif name == "Treadmill" then
return State.options.autoTreadmill
elseif name == "Springs" then
return State.options.autoSprings
elseif name == "Upgrades" then
return State.options.autoUpgrades
end
return false
end
local function taskAvailable(name)
if name == "Springs" then
return springsOpen() and springsSpot() ~= nil
elseif name == "Bench" then
return benchTarget() ~= nil
elseif name == "Treadmill" then
return treadmillSpot() ~= nil
end
return true
end
local function durationOf(name)
if name == "Farm" then
return State.options.farmSeconds
elseif name == "Bench" then
return State.options.gymSeconds
elseif name == "Treadmill" then
return State.options.runSeconds
elseif name == "Springs" then
return State.options.springsSeconds
end
return 10
end
---------------------------------------------------------------------
-- Tasks
---------------------------------------------------------------------
local function runFarm(generation)
local idleSince = os.clock()
local stuckMoves = 0
while isActive(generation) do
local hrp = rootPart()
if ghostTotal() == 0 then
-- wall finished / waiting for the next one: nothing to place
if os.clock() - idleSince > 5 then
break
end
task.wait(0.4)
elseif blocksInBag() <= 0 then
fillBag()
if not isActive(generation) then
break
end
idleSince = os.clock()
elseif not hrp then
task.wait(0.2)
else
local reach = currentReach()
State.debug.loops = State.debug.loops + 1
local searchStart = os.clock()
local ghost = ghostInReach(hrp.Position, reach)
State.debug.timeSearch = State.debug.timeSearch + (os.clock() - searchStart)
if ghost then
State.debug.fires = State.debug.fires + 1
firePlace(ghost)
task.wait(currentDelay())
local section = tostring(ghost:GetAttribute("S"))
if ghost.Name == "Block" then
State.stats.placed = State.stats.placed + 1
State.debug.accepted = State.debug.accepted + 1
State.sectionRejects[section] = 0
State.sectionCooldown[section] = nil
idleSince = os.clock()
else
State.debug.rejected = State.debug.rejected + 1
State.ghostFailures[ghost] = os.clock()
State.sectionRejects[section] = (State.sectionRejects[section] or 0) + 1
if State.sectionRejects[section] >= 5 then
State.sectionCooldown[section] = os.clock() + 8
State.sectionRejects[section] = 0
end
end
else
-- nothing left within reach: hop to the densest cluster once
local anchorStart = os.clock()
local anchor, neighbours = anchorGhost(reach)
State.debug.timeAnchor = State.debug.timeAnchor + (os.clock() - anchorStart)
local moved = false
if anchor then
local moveStart = os.clock()
moved = moveToCluster(anchor, reach) and neighbours > 0
State.debug.timeMove = State.debug.timeMove + (os.clock() - moveStart)
State.debug.moves = State.debug.moves + 1
end
if moved then
idleSince = os.clock()
stuckMoves = 0
else
State.debug.moveFails = State.debug.moveFails + 1
stuckMoves = stuckMoves + 1
if stuckMoves >= 6 and State.options.autoUnstuck and isPinned() then
local hum = humanoid()
if hum then
logger("cannot reach the wall - respawning")
hum.Health = 0
stuckMoves = 0
task.wait(3)
end
end
end
if os.clock() - idleSince > 4 then
break
end
task.wait(0.15)
end
end
end
end
local function runBench(generation)
local seat, prompt = benchTarget()
if not seat or not prompt then
return
end
local function press()
local hrp = rootPart()
if not hrp then
return
end
if (hrp.Position - seat.Position).Magnitude > 8 then
hrp.CFrame = CFrame.new(seat.Position + Vector3.new(0, 3, 0))
task.wait(0.3)
end
pcall(function()
prompt:InputHoldBegin()
end)
task.wait(0.12)
pcall(function()
prompt:InputHoldEnd()
end)
end
press()
task.wait(0.6)
local misses = 0
while isActive(generation) do
if tostring(attribute("Training", "")) ~= "Strength" then
press()
task.wait(0.5)
if tostring(attribute("Training", "")) ~= "Strength" then
misses = misses + 1
if misses >= 3 and isPinned() then
unstick()
misses = 0
press()
end
else
misses = 0
end
end
task.wait(0.35)
end
standUp()
end
local function runTreadmill(generation)
local belt = treadmillSpot()
if not belt then
return
end
local spot = belt.Position + Vector3.new(0, 1.4, 0)
local function stand()
local hrp = rootPart()
if hrp then
hrp.CFrame = CFrame.new(spot)
end
end
stand()
task.wait(0.6)
local misses = 0
while isActive(generation) do
local hrp = rootPart()
if hrp and (hrp.Position - spot).Magnitude > 6 then
stand()
misses = misses + 1
if misses >= 3 and isPinned() then
unstick()
misses = 0
stand()
end
else
misses = 0
end
task.wait(0.5)
end
end
local function runSprings(generation)
local spot = springsSpot()
if not spot or not springsOpen() then
return
end
local function soak()
local hrp = rootPart()
if not hrp then
return
end
hrp.CFrame = CFrame.new(spot)
task.wait(0.2)
local hum = humanoid()
if hum then
hum.Sit = true
end
end
soak()
local misses = 0
while isActive(generation) and springsOpen() do
local hrp = rootPart()
if hrp and (hrp.Position - spot).Magnitude > 8 then
soak()
misses = misses + 1
if misses >= 3 and isPinned() then
unstick()
misses = 0
soak()
end
else
misses = 0
end
local hum = humanoid()
if hum and not hum.Sit then
hum.Sit = true
end
task.wait(0.6)
end
standUp()
end
local function buyUpgrade(id)
local ok, result = pcall(function()
return UpgradeRemote:InvokeServer("Buy", id)
end)
if not ok then
return false, tostring(result)
end
if type(result) == "table" then
if result.ok then
return true
end
return false, tostring(result.reason or "unknown")
end
return false, "unknown"
end
local function runUpgrades(generation)
local plan = upgradePlan()
if not plan or coins() < plan.cost then
return
end
local spot = shrineSpot()
if not spot then
return
end
teleport(spot)
task.wait(0.5)
while isActive(generation) do
local nextPlan = upgradePlan()
if not nextPlan then
break
end
if coins() < nextPlan.cost then
break
end
local ok, reason = buyUpgrade(nextPlan.id)
if ok then
State.stats.upgrades = State.stats.upgrades + 1
logger("bought upgrade: " .. nextPlan.id)
elseif reason == "far" then
-- step closer, but not so close that we get pinned in the praying pose
local zone = shrineZone()
local moved = false
if zone then
for _, offset in ipairs({ 8.5, 7, 5.5 }) do
teleport(zone.Position + Vector3.new(0, -1, offset))
task.wait(0.35)
if buyUpgrade(nextPlan.id) then
State.stats.upgrades = State.stats.upgrades + 1
moved = true
break
end
end
end
if not moved then
break
end
else
logger("upgrade " .. nextPlan.id .. " rejected: " .. reason)
break
end
task.wait(0.25)
end
if isPinned() then
unstick()
end
end
local TASK_FUNCTIONS = {
Farm = runFarm,
Bench = runBench,
Treadmill = runTreadmill,
Springs = runSprings,
Upgrades = runUpgrades,
}
local function runTask(name, seconds, manual)
local fn = TASK_FUNCTIONS[name]
if not fn then
return
end
local generation = beginTask(name, seconds)
if manual then
-- let a hand-started task finish before the scheduler takes over again
State.manualUntil = State.taskUntil
end
local started = os.clock()
local ok, err = pcall(fn, generation)
if not ok then
logger("task " .. name .. " failed: " .. tostring(err))
elseif os.clock() - started < seconds - 1 then
logger(string.format("task %s ended early: %.1fs of %.1fs (generation %d -> %d)",
name, os.clock() - started, seconds, generation, State.generation))
end
if State.generation == generation then
State.task = "Idle"
end
if manual and State.manualUntil <= os.clock() then
State.manualUntil = 0
end
end
local function enabledTasks()
local list = {}
for _, name in ipairs(State.options.order) do
if taskEnabled(name) and taskAvailable(name) then
table.insert(list, name)
end
end
-- the springs may be missing from the order list; add them if they are open
if State.options.autoSprings and springsOpen() and springsSpot() then
local found = false
for _, name in ipairs(list) do
if name == "Springs" then
found = true
end
end
if not found then
table.insert(list, "Springs")
end
end
return list
end
--- Which task the rotation will switch to after the current one finishes.
local function nextTaskName()
local list = enabledTasks()
if #list == 0 then
return nil
end
for index, name in ipairs(list) do
if name == State.task then
return list[(index % #list) + 1]
end
end
return list[1]
end
--- Seconds left before the scheduler switches tasks (0 when idle).
local function taskRemaining()
if State.task == "Idle" then
return 0
end
return math.max(0, State.taskUntil - os.clock())
end
--- Fraction of the current time slice already spent (0..1).
local function taskProgress()
local duration = State.taskDuration or 0
if duration <= 0 or duration > 1e6 then
return 0
end
local remaining = taskRemaining()
return math.clamp(1 - remaining / duration, 0, 1)
end
local function schedulerLoop()
while true do
if not State.running then
task.wait(0.3)
elseif State.manualUntil > os.clock() then
-- a task started by hand (button / API) owns the character right now
task.wait(0.2)
else
local springOverride = State.options.springsPreempt
and State.options.autoSprings
and springsOpen()
and springsSpot() ~= nil
if springOverride then
local seconds = math.max(5, math.min(State.options.springsSeconds, springsTimeLeft()))
runTask("Springs", seconds)
else
local list = enabledTasks()
if #list == 0 then
State.task = "Idle"
task.wait(0.4)
elseif not State.options.autopilot then
runTask(list[1], 20)
elseif State.options.mode == "Priority" then
runTask(list[1], 25)
else
for _, name in ipairs(list) do
if not State.running then
break
end
-- a manual task (button / API) must not be preempted
if State.manualUntil > os.clock() then
break
end
if hasUpgradeOpportunity() then
runTask("Upgrades", 12)
end
runTask(name, durationOf(name))
end
end
end
end
end
end
---------------------------------------------------------------------
-- Statistics
---------------------------------------------------------------------
local lastCoins = coins()
player:GetAttributeChangedSignal("Coins"):Connect(function()
local current = coins()
if current > lastCoins then
State.stats.coinsEarned = State.stats.coinsEarned + (current - lastCoins)
end
lastCoins = current
end)
task.spawn(function()
local previous = {}
while true do
local current = {}
for _, ghost in ipairs(refreshGhosts()) do
local section = ghost:GetAttribute("S")
current[section] = (current[section] or 0) + 1
end
for section, count in pairs(previous) do
if count > 0 and (current[section] or 0) == 0 then
State.stats.stages = State.stats.stages + 1
end
end
previous = current
task.wait(2)
end
end)
-- rolling placement speed (blocks per second over the last few seconds)
task.spawn(function()
while true do
table.insert(State.rateSamples, { at = os.clock(), placed = State.stats.placed })
while #State.rateSamples > 6 do
table.remove(State.rateSamples, 1)
end
task.wait(1)
end
end)
local function placementRate()
local samples = State.rateSamples
if #samples < 2 then
return 0
end
local first = samples[1]
local last = samples[#samples]
local span = last.at - first.at
if span <= 0 then
return 0
end
return (last.placed - first.placed) / span
end
---------------------------------------------------------------------
-- UI
---------------------------------------------------------------------
-- shut down a previously loaded instance, if any
local previousInstance = getgenv().GreatWallHub
if previousInstance and type(previousInstance.destroy) == "function" then
pcall(previousInstance.destroy)
end
local Window = Airflow:CreateWindow({
Name = "Great Wall Hub",
LoadingSubtitle = "autofarm + rotation",
ToggleUIKeybind = "RightControl",
ConfigurationSaving = { Enabled = true, FolderName = "GreatWallHub" },
Home = false,
})
local FarmTab = Window:CreateTab({ Name = "Farm", Icon = "pickaxe" })
local GymTab = Window:CreateTab({ Name = "Gym", Icon = "dumbbell" })
local UpgradeTab = Window:CreateTab({ Name = "Upgrades", Icon = "trending-up" })
local RotationTab = Window:CreateTab({ Name = "Rotation", Icon = "refresh-cw" })
local InfoTab = Window:CreateTab({ Name = "Info", Icon = "info" })
---------------------------------------------------------------------
-- Farm
---------------------------------------------------------------------
local farmLeft = FarmTab:CreateGroupbox({ Name = "Quarry and wall", Side = "Left" })
local farmRight = FarmTab:CreateGroupbox({ Name = "Status", Side = "Right" })
local toggleAutoFarm = farmLeft:CreateToggle({
Name = "Auto farm",
Desc = "Mine stones in the quarry and place them on the wall",
Flag = "AutoFarm",
CurrentValue = false,
Callback = function(value)
State.options.autoFarm = value
stopTask()
end,
})
local sectionOptions = { "Auto" }
for index = 1, 16 do
table.insert(sectionOptions, tostring(index))
end
farmLeft:CreateDropdown({
Name = "Wall section",
Desc = "Auto - any available slot",
Options = sectionOptions,
CurrentOption = "Auto",
Flag = "WallSection",
Callback = function(value)
State.options.section = tostring(value)
State.ghostFailures = {}
State.sectionCooldown = {}
end,
})
farmLeft:CreateToggle({
Name = "Auto delay",
Desc = "Use the game's own PlaceCooldown (includes Swift/Blitz upgrades)",
Flag = "AutoDelay",
CurrentValue = true,
Callback = function(value)
State.options.autoDelay = value
end,
})
farmLeft:CreateSlider({
Name = "Place delay",
Desc = "Manual pacing, used only when auto delay is off",
Range = { 20, 300 },
Increment = 5,
CurrentValue = 60,
Suffix = " ms",
Flag = "PlaceDelay",
DependsOn = function()
return State.options.autoDelay == false
end,
DependsMode = "Disable",
Callback = function(value)
State.options.placeDelay = value / 1000
end,
})
farmLeft:CreateButton({
Name = "Fill bag now",
Style = "Primary",
Callback = function()
task.spawn(function()
fillBag()
end)
end,
})
farmLeft:CreateButton({
Name = "Place everything in the bag",
Style = "Primary",
Callback = function()
task.spawn(function()
runTask("Farm", 30, true)
end)
end,
})
farmLeft:CreateToggle({
Name = "Auto respawn when pinned",
Desc = "If the teleport to a slot does not apply, respawn the character",
Flag = "AutoUnstuck",
CurrentValue = true,
Callback = function(value)
State.options.autoUnstuck = value
end,
})
local bagProgress = farmRight:CreateProgress({
Name = "Bag",
CurrentValue = 0,
Format = function()
return tostring(blocksInBag()) .. " / " .. tostring(capacity())
end,
})
farmRight:CreateStatus({
Name = "Wallet",
Style = "Row",
Pin = true,
Update = function()
return "" .. formatNumber(coins()) .. ""
end,
})
farmRight:CreateStatus({
Name = "Open slots",
Style = "Row",
Update = function()
return "" .. tostring(ghostTotal()) .. ""
end,
})
farmRight:CreateStatus({
Name = "Placed",
Style = "Row",
Update = function()
return formatNumber(State.stats.placed)
end,
})
farmRight:CreateStatus({
Name = "Speed",
Style = "Row",
Pin = true,
Update = function()
local rate = placementRate()
local tone = rate >= 8 and "Success" or (rate >= 3 and "Accent" or "Muted")
return string.format("<%s>%.1f blk/s%s>", tone, rate, tone)
end,
})
farmRight:CreateStatus({
Name = "Stages done",
Style = "Row",
Update = function()
return formatNumber(State.stats.stages)
end,
})
task.spawn(function()
while true do
local total = math.max(1, capacity())
local current = math.min(total, blocksInBag())
pcall(function()
bagProgress:Set(current / total)
end)
task.wait(0.5)
end
end)
---------------------------------------------------------------------
-- Gym
---------------------------------------------------------------------
local gymLeft = GymTab:CreateGroupbox({ Name = "Machines", Side = "Left" })
local gymRight = GymTab:CreateGroupbox({ Name = "Status", Side = "Right" })
local toggleAutoBench = gymLeft:CreateToggle({
Name = "Auto Bench",
Desc = "Bench press -> Strength (bag size)",
Flag = "AutoBench",
CurrentValue = false,
Callback = function(value)
State.options.autoBench = value
stopTask()
end,
})
local toggleAutoTreadmill = gymLeft:CreateToggle({
Name = "Auto Treadmill",
Desc = "Treadmill -> Speed (run speed)",
Flag = "AutoTreadmill",
CurrentValue = false,
Callback = function(value)
State.options.autoTreadmill = value
stopTask()
end,
})
local toggleAutoSprings = gymLeft:CreateToggle({
Name = "Hot Spring stay",
Desc = "Soak in the spring, only while it is open (2x training)",
Flag = "AutoSprings",
CurrentValue = false,
Callback = function(value)
State.options.autoSprings = value
stopTask()
end,
})
gymLeft:CreateToggle({
Name = "Spring priority",
Desc = "While the spring is open, go there before the queue",
Flag = "SpringsPreempt",
CurrentValue = true,
Callback = function(value)
State.options.springsPreempt = value
end,
})
gymRight:CreateStatus({
Name = "Training",
Style = "Row",
Pin = true,
Update = function()
local training = tostring(attribute("Training", ""))
if training == "Strength" then
return "Strength"
elseif training == "Speed" then
return "Speed"
end
return "none"
end,
})
gymRight:CreateStatus({
Name = "Gym plot",
Style = "Row",
Update = function()
local _, multiplier = bestGymPlot()
return "x" .. tostring(multiplier)
end,
})
gymRight:CreateStatus({
Name = "Hot Spring",
Style = "Row",
Update = function()
if springsOpen() then
return "open " .. tostring(math.floor(springsTimeLeft())) .. "s"
end
return "closed"
end,
})
gymRight:CreateStatus({
Name = "Strength",
Style = "Row",
Update = function()
return formatNumber(attribute("Strength", 0))
end,
})
gymRight:CreateStatus({
Name = "Speed",
Style = "Row",
Update = function()
return formatNumber(attribute("Speed", 0))
end,
})
---------------------------------------------------------------------
-- Upgrades
---------------------------------------------------------------------
local upgradeLeft = UpgradeTab:CreateGroupbox({ Name = "Autobuy", Side = "Left" })
local upgradeRight = UpgradeTab:CreateGroupbox({ Name = "Plan", Side = "Right" })
local toggleAutoUpgrades = upgradeLeft:CreateToggle({
Name = "Auto buy upgrades",
Desc = "Buys at the altar whenever there are enough coins",
Flag = "AutoUpgrades",
CurrentValue = false,
Callback = function(value)
State.options.autoUpgrades = value
stopTask()
end,
})
upgradeLeft:CreateDropdown({
Name = "Strategy",
Options = { "In order", "Cheapest first" },
CurrentOption = "In order",
Flag = "UpgradeStrategy",
Callback = function(value)
State.options.upgradeStrategy = tostring(value)
end,
})
upgradeLeft:CreateOrderList({
Name = "Upgrade priority",
Items = State.options.upgradeOrder,
Flag = "UpgradeOrder",
Callback = function(order)
State.options.upgradeOrder = order
end,
})
upgradeLeft:CreateButton({
Name = "Buy one upgrade",
Style = "Primary",
Callback = function()
task.spawn(function()
runTask("Upgrades", 15, true)
end)
end,
})
upgradeRight:CreateStatus({
Name = "Next purchase",
Style = "Row",
Pin = true,
Update = function()
local plan = upgradePlan()
if not plan then
return "everything is maxed"
end
local enough = coins() >= plan.cost
local color = enough and "Success" or "Muted"
return "<" .. color .. ">" .. plan.id .. " - " .. formatNumber(plan.cost) .. "" .. color .. ">"
end,
})
local upgradeTable
upgradeTable = upgradeRight:CreateTable({
Name = "Levels",
Columns = {
{ Name = "Upgrade", Key = "id", Width = 110 },
{ Name = "Lvl", Key = "level", Width = 55, Align = "Center" },
{ Name = "Price", Key = "cost", Width = 80, Align = "Right" },
},
Rows = {},
MaxRows = 9,
})
task.spawn(function()
while true do
local rows = {}
for _, info in ipairs(UPGRADE_LIST) do
local cost = upgradeCost(info.Id)
table.insert(rows, {
id = info.Id,
level = tostring(upgradeLevel(info.Id)) .. "/" .. tostring(info.Max or 0),
cost = cost and formatNumber(cost) or "MAX",
})
end
pcall(function()
upgradeTable:SetRows(rows)
end)
task.wait(2)
end
end)
---------------------------------------------------------------------
-- Rotation
---------------------------------------------------------------------
local rotationLeft = RotationTab:CreateGroupbox({ Name = "Autopilot", Side = "Left" })
local rotationRight = RotationTab:CreateGroupbox({ Name = "Timings", Side = "Right" })
local ROTATION_HANDLES = {
Farm = toggleAutoFarm,
Treadmill = toggleAutoTreadmill,
Bench = toggleAutoBench,
Springs = toggleAutoSprings,
}
local ROTATION_OPTIONS = {
Farm = "autoFarm",
Treadmill = "autoTreadmill",
Bench = "autoBench",
Springs = "autoSprings",
}
--- Autopilot is a master switch: it turns every rotation task on, and turns
--- them all off again when it is cancelled. State and UI are updated directly
--- (callback skipped) so the result never depends on the toggle's stored value.
local function setRotationTasks(enabled)
for name, handle in pairs(ROTATION_HANDLES) do
State.options[ROTATION_OPTIONS[name]] = enabled
pcall(function()
handle:Set(enabled, true)
end)
end
logger("autopilot " .. (enabled and "on" or "off") .. " - rotation tasks " .. (enabled and "enabled" or "disabled"))
end
local toggleAutopilot = rotationLeft:CreateToggle({
Name = "Autopilot",
Desc = "Enables every rotation task and cycles them; turning it off disables them",
Flag = "Autopilot",
CurrentValue = false,
Callback = function(value)
State.options.autopilot = value
setRotationTasks(value)
stopTask()
end,
})
rotationLeft:CreateDropdown({
Name = "Mode",
Desc = "Rotation - round robin; Priority - top task only",
Options = { "Rotation", "Priority" },
CurrentOption = "Rotation",
Flag = "SchedulerMode",
Callback = function(value)
State.options.mode = tostring(value)
stopTask()
end,
})
rotationLeft:CreateOrderList({
Name = "Task priority",
Desc = "The order the rotation walks through",
Items = State.options.order,
Flag = "TaskOrder",
Callback = function(order)
State.options.order = order
stopTask()
end,
})
rotationRight:CreateSlider({
Name = "Farm",
Range = { 5, 300 },
Increment = 5,
CurrentValue = 30,
Suffix = " s",
Flag = "FarmSeconds",
Callback = function(value)
State.options.farmSeconds = value
end,
})
rotationRight:CreateSlider({
Name = "Run (Speed)",
Range = { 5, 300 },
Increment = 5,
CurrentValue = 30,
Suffix = " s",
Flag = "RunSeconds",
Callback = function(value)
State.options.runSeconds = value
end,
})
rotationRight:CreateSlider({
Name = "Gym (Strength)",
Range = { 5, 300 },
Increment = 5,
CurrentValue = 30,
Suffix = " s",
Flag = "GymSeconds",
Callback = function(value)
State.options.gymSeconds = value
end,
})
rotationRight:CreateSlider({
Name = "Hot spring",
Range = { 5, 120 },
Increment = 5,
CurrentValue = 20,
Suffix = " s",
Flag = "SpringsSeconds",
Callback = function(value)
State.options.springsSeconds = value
end,
})
rotationRight:CreateStatus({
Name = "Now",
Style = "Row",
Pin = true,
Update = function()
return "" .. (TASK_LABELS[State.task] or tostring(State.task)) .. ""
end,
})
rotationRight:CreateStatus({
Name = "Next switch",
Style = "Row",
Pin = true,
Update = function()
local remaining = taskRemaining()
if State.task == "Idle" or remaining <= 0 then
return "waiting"
end
local nextName = nextTaskName()
local nextLabel = nextName and (TASK_LABELS[nextName] or nextName) or "-"
return string.format("%ds -> %s", math.ceil(remaining), nextLabel)
end,
})
local sliceProgress = rotationRight:CreateProgress({
Name = "Current slice",
CurrentValue = 0,
Format = function()
return string.format("%d%%", math.floor(taskProgress() * 100))
end,
})
task.spawn(function()
while true do
pcall(function()
sliceProgress:Set(taskProgress())
end)
task.wait(0.25)
end
end)
---------------------------------------------------------------------
-- Info
---------------------------------------------------------------------
local infoLeft = InfoTab:CreateGroupbox({ Name = "Statistics", Side = "Left" })
local infoRight = InfoTab:CreateGroupbox({ Name = "Controls", Side = "Right" })
infoLeft:CreateLabel({
Text = "Session",
Update = function()
local minutes = math.floor((os.clock() - State.stats.startedAt) / 60)
return "Running for: " .. tostring(minutes) .. " min"
end,
})
infoLeft:CreateStatus({
Name = "Coins earned",
Style = "Row",
Update = function()
return formatNumber(State.stats.coinsEarned)
end,
})
infoLeft:CreateStatus({
Name = "Upgrades bought",
Style = "Row",
Update = function()
return tostring(State.stats.upgrades)
end,
})
infoLeft:CreateStatus({
Name = "Walls built",
Style = "Row",
Update = function()
return tostring(attribute("Walls", 0))
end,
})
infoRight:CreateButton({
Name = "Stop: disable every task",
Style = "Danger",
Confirm = true,
Callback = function()
State.options.autoFarm = false
State.options.autoBench = false
State.options.autoTreadmill = false
State.options.autoSprings = false
State.options.autoUpgrades = false
stopTask()
logger("all tasks stopped")
end,
})
infoRight:CreateParagraph({
Title = "How it works",
Content = "Farm: teleport to the quarry, spam Fill (about 2 stones per call), then place blocks into free wall slots. "
.. "The server rejects some slots, so the script walks the list and remembers rejections. "
.. "Gym: bench press through its ProximityPrompt; run by standing on the belt. "
.. "The spring only works while it is open (GreatWall.WallBoostEnd). "
.. "Upgrades are bought at the altar when the coins are there and the level is unlocked.",
})
infoRight:CreateStatus({
Name = "Task",
Style = "Row",
Update = function()
return TASK_LABELS[State.task] or tostring(State.task)
end,
})
---------------------------------------------------------------------
-- Public API (console and automation)
-- Example: GreatWallHub.farm(20); GreatWallHub.option("autoBench", true)
---------------------------------------------------------------------
getgenv().GreatWallHub = {
State = State,
stats = State.stats,
debug = State.debug,
option = function(name, value)
State.options[name] = value
return State.options[name]
end,
stop = stopTask,
farm = function(seconds)
runTask("Farm", seconds or 30, true)
end,
bench = function(seconds)
runTask("Bench", seconds or 30, true)
end,
treadmill = function(seconds)
runTask("Treadmill", seconds or 30, true)
end,
springs = function(seconds)
runTask("Springs", seconds or 20, true)
end,
upgrades = function(seconds)
runTask("Upgrades", seconds or 15, true)
end,
fillBag = fillBag,
ghostCount = ghostTotal,
springsOpen = springsOpen,
plan = upgradePlan,
unstick = unstick,
remaining = taskRemaining,
nextTask = nextTaskName,
rate = placementRate,
rotationTasks = setRotationTasks,
window = Window,
ui = {
autoFarm = toggleAutoFarm,
autoBench = toggleAutoBench,
autoTreadmill = toggleAutoTreadmill,
autoSprings = toggleAutoSprings,
autoUpgrades = toggleAutoUpgrades,
autopilot = toggleAutopilot,
},
destroy = function()
State.running = false
State.options.autoFarm = false
State.options.autoBench = false
State.options.autoTreadmill = false
State.options.autoSprings = false
State.options.autoUpgrades = false
stopTask()
pcall(function()
Window:Destroy()
end)
if getgenv().GreatWallHub and getgenv().GreatWallHub.window == Window then
getgenv().GreatWallHub = nil
end
end,
}
---------------------------------------------------------------------
-- Start
---------------------------------------------------------------------
Window:LoadAutoload()
-- A restored "Autopilot = true" must also restore its meaning (all tasks on);
-- config application skips callbacks, so sync it once here.
if State.options.autopilot then
setRotationTasks(true)
pcall(function()
toggleAutopilot:Set(true, true)
end)
end
task.spawn(schedulerLoop)
logger("loaded. RightControl - show/hide the window")
pcall(function()
Window:Notify({
Title = "Great Wall Hub",
Content = "Started. RightControl hides the window.",
Type = "Success",
Duration = 6,
})
end)