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
| Constant | Meaning |
|---|
xi.questStatus.QUEST_AVAILABLE | Quest not yet started |
xi.questStatus.QUEST_ACCEPTED | Quest in progress |
xi.questStatus.QUEST_COMPLETED | Quest 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().