Skip to main content

File locations

Quest and mission scripts live under:
scripts/quests/
  sandoria/
  bastok/
  windurst/
  jeuno/
  otherAreas/
  ahtUrhgan/
  adoulin/
  ...
  some_quest.lua     # quest files can also be at the top level

scripts/missions/
  sandoria/
  bastok/
  windurst/
  zilart/
  copse/
  ...
Each quest or mission is a single Lua file that creates a container object, defines its sections, and registers itself.

Creating a quest container

Require the interaction framework and create a Quest object with the area and quest ID:
require('scripts/globals/interaction/quest')
require('scripts/globals/npc_util')
---@type TQuest
local quest = Quest:new(xi.questLog.SANDORIA, xi.quest.id.sandoria.SOME_QUEST)

Quest status constants

ConstantMeaning
xi.questStatus.QUEST_AVAILABLEQuest not yet started
xi.questStatus.QUEST_ACCEPTEDQuest in progress
xi.questStatus.QUEST_COMPLETEDQuest finished
The status argument in check is one of these values:
check = function(player, status, vars)
    return status == xi.questStatus.QUEST_AVAILABLE
end,

Container helpers

The container object exposes helper methods for variable management and quest state transitions.

Quest lifecycle

quest:begin(player)    -- adds quest to player's quest log
quest:complete(player) -- completes the quest and applies quest.reward
quest:complete() calls npcUtil.completeQuest() internally and returns true on success (i.e., when the player has inventory space for any reward items).

Quest reward table

Set quest.reward at the top of the file to define what quest:complete() gives:
quest.reward =
{
    item     = { xi.item.CHUNK_OF_COPPER_ORE, 2 },
    keyItem  = xi.ki.ZERUHN_REPORT,
    fameArea = xi.fameArea.BASTOK,
    fame     = 120,
    gil      = 500,
    exp      = 1000,
    title    = xi.title.ENTRANCE_DENIED,
}

Variable helpers

Variables are stored in char_var with the prefix Quest[<areaId>][<questId>].
-- Get and set quest variables (stored as Quest[X][Y]<name>)
quest:getVar(player, 'Prog')           -- returns current value
quest:setVar(player, 'Prog', 1)        -- sets value
quest:incrementVar(player, 'Prog', 1)  -- increments by 1

Bit variable helpers

For tracking multiple boolean flags in a single variable:
quest:setVarBit(player, 'Prog', 3)        -- sets bit 3
quest:unsetVarBit(player, 'Prog', 3)      -- clears bit 3
quest:isVarBitsSet(player, 'Prog', 1, 3)  -- true if bit 1 OR bit 3 is set

Defining sections

A quest’s sections are defined as an array at the end of the file:
quest.sections =
{
    -- Section 1: quest available
    {
        check = function(player, status, vars)
            return status == xi.questStatus.QUEST_AVAILABLE
        end,

        [xi.zone.BASTOK_MARKETS] =
        {
            ['Some_NPC'] =
            {
                onTrigger = function(player, npc)
                    return quest:progressEvent(100)
                end,
            },

            onEventFinish =
            {
                [100] = function(player, csid, option, npc)
                    if option == 1 then
                        quest:begin(player)
                        quest:setVar(player, 'Prog', 1)
                    end
                end,
            },
        },
    },

    -- Section 2: quest accepted, prog == 1
    {
        check = function(player, status, vars)
            return status == xi.questStatus.QUEST_ACCEPTED and vars.Prog == 1
        end,

        [xi.zone.BASTOK_MARKETS] =
        {
            ['Reward_NPC'] =
            {
                onTrigger = function(player, npc)
                    if player:getFreeSlotsCount() > 0 then
                        return quest:progressEvent(200)
                    else
                        return quest:event(199)
                    end
                end,
            },

            onEventFinish =
            {
                [200] = function(player, csid, option, npc)
                    quest:complete(player)
                end,
            },
        },
    },
}

return quest

Registering the container

Add the quest to scripts/globals/interaction_containers.lua so the framework loads it:
-- In interaction_containers.lua:
require('scripts/quests/bastok/some_quest')
The name used in !reloadquest must match exactly the filename as required in interaction_containers.lua. It is case-sensitive.

Mission containers

Missions use the same structure but with a Mission container:
require('scripts/globals/interaction/mission')
---@type TMission
local mission = Mission:new(xi.mission.log_id.SANDORIA, xi.mission.id.sandoria.SOME_MISSION)
The Mission class has mission:complete() which calls npcUtil.completeMission() and supports the same reward params, plus rank, rankPoints, and nextMission.

Hidden quests

For quests that do not appear in the quest log, use HiddenQuest:
require('scripts/globals/interaction/hidden_quest')
local hq = HiddenQuest:new('some_unique_var_prefix')
HiddenQuest uses npcUtil.giveReward() directly instead of npcUtil.completeQuest().

Build docs developers (and LLMs) love