Quest and Dialog
Both controllers are always available on a client. Between them they cover "where should I go next" and "get me through this conversation".
QuestController
c.quest:GetActive() --> QuestInfo[]
c.quest:GetActiveQuest() --> Quest|nil
c.quest:GetGoals() --> GoalInfo[]
c.quest:GetPosition() --> Position|nil
GetActive() returns flat data tables for every incomplete quest — finished quests are
not in the list. GetActiveQuest() returns a richer Quest object for the currently selected
one, with methods rather than fields.
GetPosition() is the shortcut you usually want: the coordinates of the current objective,
or nil if there is nothing to go to.
GetGoals() is one goal per quest, not every goalIt returns the active goal of each incomplete quest, tagged with quest_name. A quest
whose active goal cannot be resolved is skipped entirely, so #c.quest:GetGoals() is usually
smaller than #c.quest:GetActive() and never larger. For all the goals of one quest, use
q:Goals() on the Quest object.
Both list getters return an empty table when the quest manager cannot be read; there is no
error return. GetActiveQuest(), c:ActiveQuest(), and GetPosition() return nil on the
same failure.
QuestInfo
Plain table, returned by GetActive():
| Field | Type |
|---|---|
name | string |
level | number |
is_mainline | boolean |
type | Mainline | Side | Crafting | Fishing | Gardening | Pet | Housing | PVP | Event | Unknown |
turn_in_ready | boolean |
progress_completed, progress_total | number |
GoalInfo
Plain table, returned by GetGoals() — one per incomplete quest, describing that quest's
active goal:
| Field | Type | Notes |
|---|---|---|
quest_name | string | Which quest this goal belongs to |
name | string | The goal's display name |
type | GoalTypeName | Bounty, Persona, Scavenge, Waypoint, … |
is_complete | boolean | |
destination_zone | string | '' when the goal names no zone |
Unlike the Goal object below, this is a flat snapshot: it is read once and never refreshes.
There is no HasDestinationZone here, so treat '' as "no destination".
Quest
Object with methods, returned by GetActiveQuest() and c:ActiveQuest():
q:Name() --> string
q:Level() --> number
q:Type() --> string
q:IsMainline() --> boolean
q:IsTurnInReady() --> boolean
q:IsComplete() --> boolean
q:Progress() --> completed, total
q:Position() --> Position|nil
q:ActiveGoal() --> Goal|nil
q:Goals() --> Goal[]
Every accessor swallows read errors and hands back a zero value — '', 0, false, nil,
or 0, 0 from Progress(). An empty name means "could not read", not "unnamed quest".
A Quest is a live handle into the game's memory, not a snapshot: each method re-reads when
you call it. Fetch it fresh at the top of a tick rather than caching one across ticks, or you
will eventually be reading a quest that has been turned in.
Goal
g:Name() --> string
g:Type() --> string
g:DestinationZone() --> string
g:IsComplete() --> boolean
g:HasDestinationZone() --> boolean
HasDestinationZone() before DestinationZone() — an empty string and "no destination" are
different things, and the boolean is the reliable test.
Goal types include Bounty, BountyCollect, Scavenge, Persona, Waypoint,
AchieveRank, Usage, CompleteQuest, and several social variants. Persona means "talk to
someone", Bounty means "kill things", Scavenge means "collect objects off the ground".
Branching on goal type is how a quest bot decides whether to fight, click, or talk.
Following a quest
local function advance_quest(c)
local q = c:ActiveQuest()
if not q then
log_throttled('noquest', 'no active quest')
return
end
local goal = q:ActiveGoal()
if goal and goal:HasDestinationZone() and goal:DestinationZone() ~= c:GetZone() then
log('goal is in ' .. goal:DestinationZone() .. ', need a transition')
return
end
local result, err = c:QuestTeleport()
if not result then
log_throttled('qtp', 'quest teleport failed: ' .. tostring(err))
return
end
if result.outcome == 'combat_entered' then
state.phase = 'fight'
elseif result.outcome == 'zone_required' then
log('quest needs a zone change to ' .. tostring(result.to_zone))
end
end
Checking the goal's destination zone before teleporting saves a pointless attempt. A positional teleport cannot cross zones, so a goal elsewhere needs a door.
combat_entered is a success outcome, not a failure. A quest teleport that walks you into an
ambush did its job.
DialogController
c.dialog:IsOpen() --> boolean
c.dialog:Advance() --> ok, err
c.dialog:Skip(opts) --> ok, err
Advance() moves one dialog page forward. Skip() runs the whole conversation to the end.
IsOpen() returns false both when no dialog is open and when the read failed.
c.dialog:Skip({ accept_quests = true, yes_no_choice = 'yes' })
| Option | Values | Default | Effect |
|---|---|---|---|
accept_quests | boolean | true | Accept quests offered during the conversation |
yes_no_choice | yes | no | anything else | unanswered | Answer to yes/no prompts |
accept_quests defaults to true, including when you pass no options at all. A bare
c.dialog:Skip() will accept quests. Pass { accept_quests = false } if that is not what you
want.
Only the literal strings yes and no set an answer. 'none', a typo, or omitting the field
all leave prompts unanswered — which stops the skip rather than guessing.
ok == true does not mean the dialog closedSkip returns true, nil in three different situations: the conversation ran to the end, it
paused at a yes/no prompt it was not told how to answer, and it paused at a quest offer
it was told not to accept. Both pauses are deliberate refusals to click something you did not
authorise, and both look like success.
The reliable test is the state itself, on a later tick:
c.dialog:Skip({ accept_quests = true, yes_no_choice = 'yes' })
if c:IsInDialog() then
log_throttled('dialog', 'dialog still open after skip')
end
A real failure — no dialog open, or a client that could not be reached — comes back as
false, err.
Dialogs in a tick loop
Dialogs interrupt everything. A robust plugin checks for one before doing anything else:
local function tick()
local c = ready_client()
if not c then return end
if c:IsInDialog() then
c.dialog:Skip({ accept_quests = true, yes_no_choice = 'yes' })
state.wait = utils.Ticks(1)
return
end
-- ...
end
You can also react to the event:
plugin.on_dialog_open = function(data)
if not belongs_to_us(data) then return end
local c = clients:Get(data.client_id)
if c then c.dialog:Skip({ accept_quests = true }) end
end
The polling form is more reliable for a bot that must not get stuck — a missed
dialog_open event leaves an event-only plugin waiting forever, whereas the poll notices on
the next tick.
Skip presses space repeatedly and falls back to clicking the advance control. It does not
call the game's dialog function directly — doing so crashes the client, and executing game
code is the behaviour that gets accounts banned. Everything here goes through the same input
path a player uses.