AutoPilot is a Mudlet plugin for the LOTJ (Legends of the Jedi) MUD that automates space travel and cargo hauling. Built using Lua, it provides both command-line interface and a comprehensive GUI for managing ships, routes, manifests, and automated flight.
Key Technologies:
- Mudlet MUD Client
- Lua scripting language
- Geyser GUI framework
- Muddler development paradigm
GitHub Repository: https://github.com/Xavious/AutoPilot
All persistent data is stored in a global autopilot table that is saved to disk using Mudlet's table.save() function:
autopilot = {
ships = {}, -- Array of ship templates
ship = {}, -- Currently loaded ship configuration
routes = {}, -- Array of route templates (old cargo system)
manifests = {}, -- Array of manifest templates (new cargo system)
currentRoute = nil, -- Currently loaded route
currentManifest = nil,-- Currently loaded manifest
preferredPads = {}, -- Table mapping planet names to preferred landing pads
runningCargo = false, -- Boolean flag for cargo automation
useContraband = false,-- Boolean flag for contraband hauling
-- Flight state
destination = {}, -- Current destination planet/sector info
waypoints = {}, -- Array of remaining waypoints
-- Cargo tracking
expense = 0,
revenue = 0,
fuelCost = 0,
startTime = 0,
profit = 0
}Persistence: All changes to autopilot.ships, autopilot.routes, autopilot.manifests, and autopilot.preferredPads must be followed by:
table.save(getMudletHomeDir().."/AutoPilot.lua", autopilot)/src/
/scripts/
autopilot.script.lua # Main script file (~2750+ lines)
/aliases/
autopilot.alias.*.lua # Individual alias command files
/triggers/
triggers.json # Top-level trigger group definitions
/autopilot.flight/
triggers.json # Flight automation triggers
/autopilot.cargo/
triggers.json # Cargo automation triggers
/build/filtered/ # Build output (mirrors src/ structure)
Key File: Nearly all functionality is in src/scripts/autopilot.script.lua. This contains:
- All GUI code
- Core flight/cargo automation logic
- Helper functions
- Command-line alias implementations
The GUI uses the Geyser framework with a view stack navigation pattern:
autopilot.gui = {
window = nil, -- Main Geyser.Label container
header = nil, -- Tab navigation bar
content = nil, -- Main Geyser.MiniConsole for content display
formContainer = nil, -- Geyser.Label for forms (hidden when not in use)
-- View stack
viewStack = {}, -- Navigation history
-- Form state
formData = nil, -- Current form's input data
workingManifest = nil, -- Temporary manifest being edited
workingManifestIndex = nil, -- Index in autopilot.manifests if editing existing
manifestEditor = nil, -- Flag/state object indicating manifest editor is active
-- Configuration
config = {
window = {x, y, width, height},
header = {x, y, width, height},
content = {x, y, width, height}
},
colors = {
background = "#1a1a1a",
border = "#333333",
header_bg = "#2a2a2a",
-- etc.
}
}Navigation works via push/pop:
autopilot.pushView(viewFunc) -- Add new view to stack, execute it
autopilot.popView() -- Go back to previous viewWhen popView() is called, it calls refreshGUI() which checks for active manifest editor:
function autopilot.refreshGUI()
if autopilot.gui.workingManifest and autopilot.gui.manifestEditor then
autopilot.refreshManifestEditor()
else
autopilot.popView()
end
endCRITICAL: The refreshGUI() function is used extensively. Be careful not to break the manifest editor check logic.
The GUI uses a working copy pattern for editing manifests:
- When editing starts, create deep copy:
autopilot.gui.workingManifest = table.deepcopy(autopilot.manifests[index]) - All edits modify
workingManifestonly - Final "Save" button writes
workingManifesttoautopilot.manifestsand disk
Dual Save Pattern:
- Editing EXISTING manifest: Changes save immediately to both
autopilot.manifests[index]AND disk - Creating NEW manifest: Changes only update
workingManifest, save to disk happens when final Save clicked
Example from editing delivery in existing manifest:
local manifestIndex = autopilot.gui.workingManifestIndex
if manifestIndex then
-- Editing existing manifest - update saved copy and save to disk
autopilot.manifests[manifestIndex].deliveries[deliveryIndex] = updatedDelivery
autopilot.gui.workingManifest.deliveries[deliveryIndex] = updatedDelivery
table.save(getMudletHomeDir().."/AutoPilot.lua", autopilot)
else
-- Editing delivery in new manifest - only update working copy
autopilot.gui.workingManifest.deliveries[deliveryIndex] = updatedDelivery
endGeneral-purpose container and button element:
local label = Geyser.Label:new({
name = "unique_name",
x = "10%", y = "5%", -- Percentage-based positioning
width = "80%", height = "10%",
}, parent)
label:setStyleSheet([[background-color: #1a1a1a;]])
label:setClickCallback(function() ... end) -- For buttonsScrollable text display with color support:
local console = Geyser.MiniConsole:new({
name = "unique_name",
x = "5%", y = "10%",
width = "90%", height = "80%",
autoWrap = true,
scrollBar = true,
fontSize = 12
}, parent)
console:clear()
console:cecho("<white>Text with <cyan>colors<reset>\n")
console:fg("yellow")
console:echoLink("[Click Me]", [[send("command")]], "Tooltip text", true)
console:resetFormat()IMPORTANT: Always specify fontSize = 12 for consistency.
Text input widget:
local input = Geyser.CommandLine:new({
name = "unique_name",
x = "5%", y = "50%",
width = "90%", height = "5%",
fontSize = 12
}, parent)IMPORTANT: Always use percentage-based positioning (never pixels) for responsive scaling.
Forms use autopilot.gui.formContainer which is created/destroyed via autopilot.cleanupFormUI():
function autopilot.cleanupFormUI()
-- Destroy the entire formContainer (and all its children)
if autopilot.gui.formContainer then
autopilot.gui.formContainer:hide()
autopilot.gui.formContainer = nil
end
-- Recreate a fresh formContainer
autopilot.gui.formContainer = Geyser.Label:new({...})
autopilot.gui.formContainer:hide()
-- Clear form data
autopilot.gui.formData = nil
-- NOTE: Don't clear manifestEditor or workingManifest here!
-- They're needed by manifest editor callbacks
}CRITICAL: Do NOT clear manifestEditor or workingManifest in cleanupFormUI() - this was a major bug.
The manifest editor has special handling because it's not a simple form:
function autopilot.showManifestEditor(manifestIndex)
autopilot.cleanupFormUI() -- Clean up first
local isEdit = manifestIndex ~= nil
-- Always create a fresh working manifest (CRITICAL: no conditional preservation)
local manifest = manifestIndex and table.deepcopy(autopilot.manifests[manifestIndex]) or {name = "", deliveries = {}}
autopilot.gui.workingManifest = manifest
autopilot.gui.workingManifestIndex = manifestIndex
-- Store references
autopilot.gui.manifestEditor = {
isEdit = isEdit,
manifestIndex = manifestIndex
}
-- Display in main content area (not formContainer)
autopilot.gui.formContainer:hide()
autopilot.gui.content:show()
autopilot.refreshManifestEditor()
endfunction autopilot.refreshManifestEditor()
if not autopilot.gui.manifestEditor or not autopilot.gui.workingManifest then
return
end
-- CRITICAL: Ensure form container is hidden and content is shown
autopilot.gui.formContainer:hide()
autopilot.gui.content:show()
local console = autopilot.gui.content
console:clear()
-- Display manifest name, deliveries, buttons, etc.
-- Uses echoLink for interactive elements
}Key Gotchas:
- Always create fresh
workingManifestwithout conditional checks - Manifest editor uses main
contentconsole, NOTformContainer refreshManifestEditor()must hide formContainer and show content- Don't clear
manifestEditorflag incleanupFormUI()
Triggers are organized into groups:
- autopilot.flight - Flight automation triggers (orbit, hyperspace, landing, etc.)
- autopilot.cargo - Cargo purchase/sale tracking triggers
Enable/disable with:
enableTrigger("autopilot.flight")
disableTrigger("autopilot.flight")Triggers are defined in JSON files with regex patterns:
{
"name": "autopilot.trigger.orbit",
"isActive": "yes",
"patterns": [
{
"pattern": "^You are now orbiting (?<planet>\\w+)\\.$",
"type": "regex"
}
],
"script": ""
}The actual trigger logic is in autopilot.script.lua, e.g.:
function autopilot.trigger.orbit()
local planet = matches.planet
local preferredPad = autopilot.getPreferredPad(planet)
if preferredPad then
send("land '"..planet.."' "..preferredPad)
else
send("land "..planet)
end
endCRITICAL: When editing regex patterns in JSON:
- Use proper angle brackets
<and>for named groups - NEVER use Unicode escapes like
\u003cor\u003e - Pattern format:
(?<groupName>pattern)
Located in autopilot.script.lua:
Starting a flight:
autopilot.alias.fly("planet1,planet2,planet3")This:
- Parses comma-separated planet list into
autopilot.waypoints - Sets first waypoint as
autopilot.destination.planet - Enables
autopilot.flighttrigger group - Opens ship and enters cockpit
Trigger flow:
trigger.inCockpit()→launchtrigger.space()→autopilot "planet"trigger.hyperspace()→ waitstrigger.orbit()→land(with preferred pad if set)trigger.land()→ checks for more waypoints, repeats or exits ship
Preferred Pads:
- Stored in
autopilot.preferredPads[planetName:lower()] = padNumber - Logic fires in
trigger.orbit()(NOT instartLanding())
Routes (old system):
- Simple list of planets:
{name = "Route Name", planets = {"planet1", "planet2"}} - Used for flying only, no cargo
Manifests (new system):
- More complex:
{name = "Manifest Name", deliveries = [...]} - Each delivery:
{planet = "...", resource = "...", route = routeIndex} - Deliveries can specify a route to follow OR fly direct
Always use this for consistent route display:
function autopilot.formatRouteText(routeIndex)
if not routeIndex then
return " <gray>(direct)"
end
local route = autopilot.routes[routeIndex]
if not route or not route.planets or #route.planets == 0 then
return " <gray>(route #" .. routeIndex .. ")"
end
-- Build route path: planet1 → planet2 → planet3
local routePath = ""
for i, planet in ipairs(route.planets) do
if i > 1 then
routePath = routePath .. " → "
end
routePath = routePath .. planet
end
return " <gray>(route: " .. routePath .. ")"
endGUI "Fly To" button uses:
function autopilot.flyRoute(routeIndex)
local route = autopilot.routes[routeIndex]
autopilot.currentRoute = table.deepcopy(route)
autopilot.waypoints = table.deepcopy(route.planets)
autopilot.destination = {}
autopilot.destination.planet = table.remove(autopilot.waypoints, 1)
enableTrigger("autopilot.flight")
autopilot.openShip()
endALWAYS use table.deepcopy() when copying tables to avoid reference issues:
local copy = table.deepcopy(original)When using variables in echoLink callbacks, ensure they're captured correctly:
local selectedRoute = deliveryData.route -- Capture in local variable
console:echoLink("[Save]", function()
-- selectedRoute is now properly captured in closure
delivery.route = selectedRoute
end, "Save delivery", true)When returning from a form to manifest editor:
- Form calls callback function with updated data
- Callback updates
workingManifestand optionally saves to disk - Callback calls
autopilot.refreshGUI() refreshGUI()checks forworkingManifestand callsrefreshManifestEditor()
Use MiniConsole with echoLinks (NOT Geyser flyout labels - they're buggy):
local routeConsole = Geyser.MiniConsole:new({...}, parent)
local selectedRoute = nil -- Captured in closure
local function refreshRouteConsole()
routeConsole:clear()
routeConsole:cecho("<white>Selected: <cyan>" .. (selectedRoute or "Direct") .. "\n")
-- Direct option
routeConsole:echoLink("[Direct]", function()
selectedRoute = nil
refreshRouteConsole()
end, "Direct flight", true)
-- Route options
for i, route in ipairs(autopilot.routes) do
routeConsole:echoLink("[Route "..i.."]", function()
selectedRoute = i
refreshRouteConsole()
end, "Select route "..i, true)
end
end
refreshRouteConsole() -- Initial displayALWAYS use percentages for GUI positioning:
-- GOOD
x = "10%", y = "5%", width = "80%", height = "10%"
-- BAD (don't use pixels)
x = 50, y = 100, width = 400, height = 50ALWAYS specify fontSize = 12 for MiniConsole and CommandLine:
local console = Geyser.MiniConsole:new({
fontSize = 12, -- ALWAYS include this
...
})Symptom: First click shows blank form, second click works
Cause: Preservation logic interfering with initialization
Solution: Always create fresh workingManifest without conditionals:
-- GOOD
local manifest = manifestIndex and table.deepcopy(autopilot.manifests[manifestIndex]) or {name = "", deliveries = {}}
autopilot.gui.workingManifest = manifest
-- BAD (don't preserve existing)
if not autopilot.gui.workingManifest then
autopilot.gui.workingManifest = ...
endSymptom: Edit form, save, return to editor - changes gone
Common Causes:
refreshGUI()callingshowManifestEditor()instead ofrefreshManifestEditor()cleanupFormUI()clearingmanifestEditorflag- Form container not being hidden after edit
- Saving to disk for NEW manifests (not yet in
autopilot.manifests)
Solutions:
- Ensure
refreshGUI()checks forworkingManifestexistence - Don't clear
manifestEditorincleanupFormUI() - Add hide/show logic to
refreshManifestEditor() - Implement dual save pattern (see "Working Copy Pattern" above)
Symptom: Trigger fires but matches table is empty or malformed
Cause: JSON regex patterns mangled with Unicode escapes
Solution: Check pattern uses proper angle brackets:
// GOOD
"pattern": "^You purchased (?<amount>[\\d]+) units"
// BAD (don't use Unicode escapes)
"pattern": "^You\\x1bpurchased\\x1b(?\u003camount\u003e[\\d]+)"Symptom: Window resize causes misalignment
Cause: Using pixel-based positioning instead of percentages
Solution: Convert all positioning to percentages (see "Percentage-Based Positioning" above)
Symptom: Local variable updates in callback but reverts
Cause: Variable not properly captured in closure scope
Solution: Ensure variable is local to the function creating the callback:
-- GOOD
local function createForm()
local selectedValue = initialValue -- Local to this function
button:setClickCallback(function()
selectedValue = newValue -- Captured correctly
end)
end
-- BAD (global or outer scope variable)
selectedValue = initialValue -- Outside closure scope
button:setClickCallback(function()
selectedValue = newValue -- May not persist
end)The status page (autopilot.gui.showStatus()) displays:
- Flight Triggers Status with Enable/Disable toggle
- Contraband Status with Enable/Disable toggle
- Current Ship details
- Flight Progress:
- Current destination (always shown, even if none)
- Remaining waypoints with numbered list
- Final destination marked with star (★)
- Current Route details
- Current Manifest details
Example flight progress display:
Flight Progress:
Current Destination: ➜ Kashyyyk
Remaining Waypoints:
1. Corellia
2. ★ Coruscant (Final Destination)
All commands start with ap:
Flight:
ap fly planet- Fly to single planetap fly planet1,planet2,planet3- Fly with waypoints
Ship Management:
ap set ship <name>- Set ship nameap set enter <path>- Set cockpit entry path (comma-separated)ap set exit <path>- Set hatch exit path (comma-separated)ap set hatch <code>- Set hatch codeap set capacity <amount>- Set cargo capacityap save ship- Save ship templateap load ship [#]- Load ship template
Routes:
ap save route- Save current routeap load route [#]- Load route template
Manifests:
ap add delivery <planet>:<resource>- Add delivery to manifest
Preferred Pads:
ap set pad <planet> <#>- Set preferred landing padap clear pad <planet>- Clear preferred landing pad
Cargo:
ap start cargo- Start cargo automationap stop cargo- Stop cargo automationap profit- Show profit report
Status:
ap status- Show current configurationap on- Enable triggersap off- Disable triggers
- Edit source files in
/src/ - Package with Muddler (if using)
- Test in Mudlet by loading package
- Save persistent data with
table.save(getMudletHomeDir().."/AutoPilot.lua", autopilot)
Enable debug output:
debugc("Debug message") -- Only shows if debug mode enabledCheck trigger status:
local status = getTriggerInfo("autopilot.flight")Inspect autopilot table:
display(autopilot.manifests)
display(autopilot.gui.workingManifest)The plugin includes auto-update functionality that checks GitHub for new releases:
Configuration:
autopilot.config = {
github_repo = "Xavious/AutoPilot",
update_check_done = false
}Known Issue: Auto-update doesn't trigger on session start (pending investigation)
From README.md TODO:
- Streamline flow/setup to be more intuitive
- Support for turbolifts
- Support for landing pad preference (partially implemented)
From conversation:
- Consider consolidating command-line aliases to redirect to GUI
- Potential GUI-first approach for all displays
- Code cleanup opportunities
When making changes, verify:
- Manifest editor displays correctly on first load
- Route selection persists in delivery forms
- New manifest changes save only to workingManifest
- Existing manifest changes save immediately to disk
- Form container hides after editing
- Content console shows after returning from form
- Percentage-based positioning scales correctly
- Font sizes consistent at 12pt
- Trigger patterns use proper angle brackets (not Unicode)
- Preferred pad logic fires in trigger.orbit()
- Status page shows all required information
- Enable/Disable toggles work correctly
| File | Purpose | Lines |
|---|---|---|
| src/scripts/autopilot.script.lua | Main script - all logic | ~2750+ |
| src/triggers/triggers.json | Top-level trigger groups | ~12 |
| src/triggers/autopilot.flight/triggers.json | Flight triggers | Multiple |
| src/triggers/autopilot.cargo/triggers.json | Cargo triggers | ~70 |
| README.md | User documentation | ~237 |
autopilot.gui.init()- Initialize GUIautopilot.gui.showStatus()- Status page (~2550-2650)autopilot.gui.showShips()- Ships list page (~2650-2700)autopilot.gui.showRoutes()- Routes list page (~2700-2750)autopilot.gui.showManifests()- Manifests list page (~2750-2800)autopilot.showManifestEditor(index)- Manifest editor (~1885-1909)autopilot.refreshManifestEditor()- Refresh manifest display (~1648-1670)autopilot.cleanupFormUI()- Clean up form container (~2004-2032)autopilot.pushView(func)- Push view to stackautopilot.popView()- Pop view from stackautopilot.refreshGUI()- Refresh current view
autopilot.showShipForm(index)- Ship editor formautopilot.showRouteForm(index)- Route editor formautopilot.showManifestNameDialog(callback)- Manifest name formautopilot.showDeliveryDialog(data, index, callback)- Delivery editor form (~1551-1619)
autopilot.alias.fly(planets)- Start flight (~300-340)autopilot.flyRoute(index)- Fly saved route (~312-340)autopilot.trigger.orbit()- Orbit trigger handler (~855-869)autopilot.trigger.hyperspace()- Hyperspace trigger handlerautopilot.trigger.land()- Landing trigger handlerautopilot.openShip()- Open ship hatch (~67-74)autopilot.getPreferredPad(planet)- Get preferred pad (~36-41)
autopilot.formatRouteText(index)- Format route display (~1602-1623)autopilot.tableString(s)- Parse comma-separated string (~26-33)autopilot.displayCurrentRoute()- Display current route (~43-55)autopilot.displayCurrentManifest()- Display current manifest (~57-65)
autopilot.alias.profit()- Display profit report (~76-100)autopilot.startCargo()- Start cargo automationautopilot.stopCargo()- Stop cargo automation
- Use
cecho()for colored console output - Use
send()to send MUD commands - Use
debugc()for debug messages - Follow Lua naming:
functionName(),variableName - Use descriptive variable names
- Comment complex logic
- Keep functions focused and single-purpose
This is a mature, feature-rich plugin with ~2750+ lines in the main script. Most functionality is concentrated in autopilot.script.lua. The GUI uses sophisticated patterns like view stacks, working copies, and dual save logic. Be extremely careful when modifying core systems like refreshGUI(), cleanupFormUI(), and manifest editor functions.
When in doubt:
- Read the existing code carefully
- Test changes incrementally
- Verify persistence with
display(autopilot.manifests) - Check trigger status with GUI or
getTriggerInfo() - Use
debugc()for troubleshooting
The user has invested significant effort in debugging and refining the GUI patterns. Preserve the established patterns and avoid breaking working functionality.