Skip to main content

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 goal

It 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():

FieldType
namestring
levelnumber
is_mainlineboolean
typeMainline | Side | Crafting | Fishing | Gardening | Pet | Housing | PVP | Event | Unknown
turn_in_readyboolean
progress_completed, progress_totalnumber

GoalInfo

Plain table, returned by GetGoals() — one per incomplete quest, describing that quest's active goal:

FieldTypeNotes
quest_namestringWhich quest this goal belongs to
namestringThe goal's display name
typeGoalTypeNameBounty, Persona, Scavenge, Waypoint, …
is_completeboolean
destination_zonestring'' 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' })
OptionValuesDefaultEffect
accept_questsbooleantrueAccept quests offered during the conversation
yes_no_choiceyes | no | anything elseunansweredAnswer 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 closed

Skip 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.

How skipping actually works

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.