New Quest System
Note: The following documentation may use the term "quest" and "mission" interchangeably. Despite different terminology used by the game, at its core a "mission" is just "a really important quest". As such, the same concepts of quests (ex: stages) also apply to missions and vice versa.
Contents
Introduction
Formerly, quests in DSP were "side effects" of interacting with NPCs. Logic for these quests were spread throughout all the files for the quest's associated NPCs, all through Vana'diel. You would talk to an NPC, it would play an event and set a variable in the SQL DB, and you better hope the NPC halfway across the world expects the same variable name when it's checking to see if it should play the next event.
Naturally this can make quests difficult to track and debug. More quests being implemented means more "side effect" logic in the NPC's file that you have to navigate through and hope you didn't mess up. And if there is a problem with a quest, you'll have to find the right NPC file with the mistake in it.
Now quests are consolidated into a single file for that quest. A digital "object", so to speak. When a quest is properly implemented for DSP's new quest system, you should be able to delete that quest's file, and the result should be as if SE never put the quest into retail at all.
Also, the separation of quests' logic into discrete files, in combination with built-in functions of the new quest system, means that quests shouldn't accidentally interact with each other. You won't have a quest accidentally resetting or changing progress for another quest - even if two separate scripters decide to use the same variable name inside the two different quest files they're scripting.
There are also other benefits baked into the new system like: being able to check a player's progress in the quest with a single GM command, making 100% certain that player vars and temporary key items are cleaned up after quest completion, and being able to instantly reset all progress in a quest with one GM command (for debugging purposes).
Stages
Quests progress in stages. A stage for a quest is a single, defined step that must be completed before the player can move onto the next step.
A player is automatically “on” stage 0 for every quest until they begin that quest. To clear stage 0, they must talk to the NPC that gives the quest, which will display the starting event and begin the quest, “clearing” the stage, and the player advances to be on stage 1.
The player will then have to do whatever the first step of the quest is (presumably the first action that the starting NPC directed the player to perform). This can be talking to another NPC, getting a key item, examining a location, killing a specific monster, etc. When this is done, the player advances past stage 1, and is now on stage 2.
Each stage for a quest is a required step to progress the quest. Optional NPC dialogue does not advance the stage (and is probably not even tracked). NPCs can give optional dialogue while a player is on a stage, but in most cases there should never be any “side effects” of talking to that NPC while on the stage (no stage advancements or other variables set).
Stages are things that must always be completed in the same order, and are not necessarily just one single action the player performs. For example, if the next thing the player has to do is collect 3 different key items, and the order of collecting those key items doesn’t matter, then there's just one stage, which is collect all three key items, not a stage for every individual key item. In such situations, the stage advances when all sub-elements are completed.
Think of it as an objective list in a video game – the main bullet points are stages, the sub-bullet points are just pieces that are required to advance the main bullet.
- Talk to Requester who asks for artifacts -- Stage 0
- Talk to Informant for information -- Stage 1
- Collect all magical artifacts -- Stage 2
- Get artifact 1
- Get artifact 2
- Get artifact 3
- Return artifacts to Requester -- Stage 3
Of course, if the order you collect the artifacts in does matter, then each of those artifacts is a stage:
- Talk to Requester who asks for artifacts -- Stage 0
- Talk to Informant for information -- Stage 1
- Get artifact 1 -- Stage 2
- Get artifact 2 -- Stage 3
- Get artifact 3 -- Stage 4
- Return artifacts to Requester -- Stage 5
As a more "real" example: you know COP's Three Paths? The stage after starting the mission from Cid would be "complete all three paths". Each of the three paths will need to track their own progress separately from the main stage. The very next stage would be "Return to Cid".
Vitals
Quests have "vitals" - information about them required for them to function. These are defined at the top of the quest file.
thisQuest.name = "Wayward Waypoints" thisQuest.log_id = dsp.quest.log_id.ADOULIN thisQuest.quest_id = dsp.quest.id.adoulin.WAYWARD_WAYPOINTS thisQuest.string_key = dsp.quest.string.adoulin[thisQuest.quest_id] thisQuest.repeatable = false thisQuest.var_prefix = "[Q]["..thisQuest.log_id.."]["..thisQuest.quest_id.."]"
- name
- The full, real name of the quest, don't be afraid of using spaces or apostrophes!
- log_id
- The ID of the quest log that the quest is tracked under on retail. Always use the tabled value in
dsp.quest.log_id
! Never use just the area name, or, just "ADOULIN". Never use a raw integer! - quest_id
- The integer ID of this specific quest under the quest log above. Always use the tabled value in
dsp.quest.id.(area)
! Never a raw integer! - string_key
- The
dsp.quest.string
table is built automatically by the quests global, and is a reverse-lookup table linking from a quest's integer ID back to the UNDERSCORE_CONSTANT DSP uses to refer to the quest. You should be able to leave it asdsp.quest.string.(area)[thisQuest.quest_id]
. - repeatable
- Used to determine if a quest can be repeated after the first time it has been completed.
- var_prefix
- The prefix that will be used by this specific quest's variables when they are stored inside the SQL database. This should be short and unique - just use
"[Q]["..thisQuest.log_id.."]["..thisQuest.quest_id.."]"
.
Other than the above "informational vitals", there are other "vitals" that every quest must have defined in order to operate: vars, requirements, rewards, and temporary. These are all tables that serve different functions, broken down below:
Variables
Progress through a quest is tracked by variables. The most important of these variables is the stage variable, which will be handled for you by the new quest system through your calls to thisQuest.advanceStage(player)
.
Most of the time, the stage variable will be all you need, but you may need to track other things (for example, a player's progress in a sub-path for a mission). These can be used through:
thisQuest.getVar(player, variable_name)
thisQuest.setVar(player, variable_name, value)
.
Your variable names can be whatever you like so long as they match a definition in the "additional vars" table at the top of your quest file:
thisQuest.name = "Name of my quest" thisQuest.var_prefix = "[Q][" .. thisQuest.log_id .. "][" .. thisQuest.quest_id .. "]" -- ...other vitals here thisQuest.vars = { stage = thisQuest.var_prefix additional = { ["waypoints"] = { type = dsp.quest.var.PERM, db_name = thisQuest.var_prefix .. "[1]"}, ["npcs_flagged"] = { type = dsp.quest.var.PERM, db_name = thisQuest.var_prefix .. "[2]"}, ["warning_given"] = { type = dsp.quest.var.TEMP, db_name = thisQuest.var_prefix .. "[3]"}, } }
Calls to access the variables above would be thisQuest.getVar(player, 'waypoints')
and thisQuest.setVar(player, 'warning_given', 1)
.
There are two "types" a variable can be:
- Perm
- This variable is saved for the player into the SQL DB, and is preserved across the player zoning, logging out, and the server restarting.
- Temp
- Temp vars can be set to players, NPCs, and mobs. This variable is stored in the "memory" of the entity, and is lost when an entity leaves the zone or logs out.
The "db_name" is what the variable will be stored as in either the SQL DB or memory. These should be both unique and anonymous.
- unique: Guarantees zero collision with other variables in the SQL DB.
- anonymous: Name can be re-used later without having to update the DB. Since the quest functions handle mapping the variable you're trying to get with what the variable is in the DB, you don't need to worry about having to remember or use the db_name.
If this is a new quest, just use thisQuest.var_prefix .. "[X]"
, where X is what the integer key for that variable would be.
You should only use a different convention if you are rewriting an existing quest that hasn't been migrated yet. If you are, see Rewriting Quests.
Requirements
The requirements table lists the requirements to begin the quest. These are checked for when you call thisQuest.checkRequirements(player)
.
thisQuest.requirements = { missions = { { mission_log = dsp.mission.log_id.SOA, mission_id = dsp.mission.id.soa.LIFE_ON_THE_FRONTIER } }, fame = { area = dsp.quest.fame.ADOULIN, level = 1 }, job = dsp.job.DRK, level = 50 }
- missions
- A table containing tables describing any required missions. Each table consists of a mission_log and mission_id. Both should be their definitions from
dsp.mission
, never tables or raw integers! - quests
- A table containing tables describing any required quests. Each table consists of a log_id and quest_id. Both should be their definitions from
dsp.quest
, never tables or raw integers! - fame
- A single table describing a required fame level for the quest.
area
should be the definition indsp.quest.fame
, never a table or raw integer! - job
- The current job a player needs to be on. Should be the definition from
dsp.job
, never a raw integer! - level
- The level the players needs to be currently at or above. The only place where a raw integer is acceptable, because it's required!
When called, thisQuest.checkRequirements(player)
will step through all listed requirements you have, and if the player fails to meet any of them, will return false
. If by the time it reaches the end the player didn't fail any requirements, it will return true
.
Rewards
The thing that the player is interested in, what they get when they complete the quest.
thisQuest.rewards = { items = 12289, -- Lauan Shield title = dsp.title.COURIER_EXTRAORDINAIRE, fame_area = dsp.quest.fame.SANDORIA }
- items
- Either a single integer, or a table of integers, representing the IDs of the items to give to the player. Comments saying what items these IDs represent are required!
- key_items
- Any key items to give to the player. Should be the definition from
dsp.ki
, never a raw integer! - title
- Any title to give the player. Should be the definition from
dsp.title
, never a raw integer! - fame_area
- If the quest rewards fame, what fame area it rewards to. Should be the definition in
dsp.quest.fame
, never a table or raw integer! - fame
- The amount of fame to give. If a fame_area is defined, but fame isn't, a default of 30 will be used.
- gil, bayld
- The amount of the listed currency to give to the player.
Everything you define here will be given to the player when you call thisQuest.complete(player)
. Do not give them to the player yourself.
If the player lacks the inventory for the physical item rewards, they will become "held" by the quest. All other rewards will be given to the player as normal. You can see if a quest is holding items for a player with thisQuest.holdingItem(player)
, and attempt to return it to the player with thisQuest.returnItem(player)
.
Temporary
This table lists both the physical and key items that the player temporarily acquires during the lifetime of the quest, but shouldn't possess afterwards.
thisQuest.temporary = { items = {593, 594, 595}, -- Magic Shop Parcel, Port Parcel, Pub Parcel key_items = {dsp.ki.BROKEN_HARPOON, dsp.ki.EXTRAVAGANT_HARPOON} }
- items
- A table of item IDs. Despite being inside a parent table named "temporary", these are real items which the player can trade, drop, or attempt to sell. Comments listing what these IDs represent are required!
- key_items
- Key items that are given to the player during the quest, which should be gone by the time the player completes it. Should be the definition in
dsp.ki
, never a raw integer! While you should always delete a key item when the player "should" lose it (viathisQuest.delKeyItem(player, ki)
), when called,thisQuest.complete(player)
uses this table to enforce all listed temporary key items are gone from the player upon quest completion.
Additional Quest Functions
Since quests are objects, if you need a special segment a code you intend to reuse to make the quest easier to read / more concise, you can extend the quest by defining your own functions.
These can be a simple way to make an NPC repeating the same action across multiple stages be just one line for each stage:
thisQuest.GO_PATROL = function(player, npc) -- Rising Solstice yelling at the player to go patrol return thisQuest.startEvent(player, 2551) end -- ... -- then during the quest: [dsp.quest.stage.STAGE1] = { [dsp.zone.WESTERN_ADOULIN] = { onTrigger = { ['Rising_Solstice'] = thisQuest.GO_PATROL } } } -- For however many stages that the NPC is expected to perform the action(s) written in the function
Or a way to break out more-complex logic from the stage tables to make the quest's stages easier to follow:
thisQuest.PAY_REPLACE_PARCEL = function(player, npc, trade) -- Player repaying to replace a parcel they have lost local parcel_lost = math.floor(thisQuest.getStage(player) / 2) + 1 local parcel_item_id = 592 + parcel_lost if thisQuest.tradeHas(player, trade, {{"gil", 100}}) and not player:hasItem(parcel_item_id) then return thisQuest.startEvent(player, 607 + parcel_lost) end end -- ... -- then during the quest: [dsp.quest.stage.STAGE1] = { [dsp.zone.PORT_SAN_DORIA] = { onTrade = { ['Fontoumant'] = thisQuest.PAY_REPLACE_PARCEL -- Player compensating for lost parcel } } }
When writing and using such functions, please:
- Only do so for things the script might need to perform for more than one NPC or stage
- When naming the function, use UPPERCASE_FORMAT to make it easier to distinguish between a normal
thisQuest.standardFunction
andthisQuest.YOUR_CUSTOM_FUNCTION
Quest Scripting
The actual driver of actions for a quest is the thisQuest.stages
table. This table is container of individual tables representing stages, with each containing the quest logic for just that stage.
thisQuest.stages = { -- Stage 0: Talk to Jorin, Western Adoulin, to get Broken Harpoon KI and start quest [dsp.quest.stage.STAGE0] = { [dsp.zone.WESTERN_ADOULIN] = { onTrigger = { ['Jorin'] = function(player, npc) if thisQuest.checkRequirements(player) then -- Starting quest, his harpoon is broken return thisQuest.startEvent(player, 2540) end end }, onEventFinish = { [2540] = function(player, option) -- Jorin, giving the player his broken harpoon to get it fixed if thisQuest.giveKeyItem(player, dsp.ki.BROKEN_HARPOON) then return thisQuest.begin(player) end end } } }, -- Stage 1: Talk to Shipilolo, Western Adoulin, to exchange Broken Harpoon KI for Extravagant Harpoon KI [dsp.quest.stage.STAGE1] = { [dsp.zone.WESTERN_ADOULIN] = { onTrigger = { ['Shipilolo'] = function(player, npc) -- Giving Broken Harpoon to get it fixed return thisQuest.startEvent(player, 2543) end }, onEventFinish = { [2543] = function(player, option) -- Shipilolo, fixes Broken Harpoon and advances quest if thisQuest.giveKeyItem(player, dsp.ki.EXTRAVAGANT_HARPOON) then thisQuest.delKeyItem(player, dsp.ki.BROKEN_HARPOON) return thisQuest.advanceStage(player) end end } } }, }
Stage
Stage tables are indexed by the stage value that the player should be on when the quest logic inside is expected to fire. Using a dsp.quest.stage.STAGEX
definition is required - never use magic integers as keys for stages.
Zone
Inside each stage are tables for the specific zone that a quest event takes place in. These must always be the definition from dsp.zone
, never raw integers!
Quest Event Type
Once inside the zone table for a stage, every type of NPC quest event that is relevant to the specific stage will be a table that is checked when that event is fired by the player:
- onTrade
- When the player hits Ok in a trade window in attempt to trade to a NPC
- onTrigger
- When the player speaks to or examines the NPC
- onEventFinish
- After the player finishes a cutscene event
- onMobDeath
- When the player kills a mob
Unlike in NPC files, you do not need to have empty event type tables. If you don't need a certain event type for a stage, don't include the event type in the table for that stage!
Actor or Event
Finally, in each of those event-type tables will be separate functions for every NPC or mob that has that event in the stage you are scripting.
In the case of onTrades, onTriggers, and onMobDeaths, these functions should be indexed by string key, like ['Jorin']
, to make them easier to find (taking advantage of syntax coloring). These string keys must exactly match the name that DSP gives to the NPC and uses to execute that NPC's script!
For onEventFinish, functions must be indexed by integer, like [2540]
, with the integer being the ID of the cutscene event you want to execute quest logic after.
Function Scripting
Inside the NPC/mob quest event functions you will script all your quest logic, like giving key items, accepting trades, and advancing the player's stage.
The new quest system has a library of functions to assist with this, and they should be used whenever possible, even if you "could" accomplish your goal without them. As a general rule, you shouldn't need to use player:doFoo()
inside a quest file! (And if you think you do, ask on Github or Discord to verify.)
When a quest "does something" that should divert execution flow away from other quests or "default" NPC behavior, you must return a true value from your Zone->Event->NPC function. Most of the built-in quest functions will return free booleans, so you can save yourself the effort (and save line count) by simply returning the quest function itself. But, depending on what logic may be required for an event, there might be times you have to manually return true
.
For information about all the built-in quest functions see: Quest Functions
Comments explaining the player's general goal during the stage are required, as are short summaries of cutscene events you execute. This new quest system is explicitly designed so that anyone can open the file, read it from top-to-bottom, and understand how the quest should behave without needing a wiki page open in a browser tab. If you have written a quest in the new system, and someone can't use your file as a walkthrough replacement, then you have failed.
Quest Hooks
To get quest events to trigger, you need to hook the quest into the NPCs you want to trigger it!
First, you need to have an involvedQuests
object defined for an NPC, at the top of the NPC's individual file, which includes the quest files you want that NPC to be involved with:
-- NPC: Shipilolo ----------------------------------- require("scripts/globals/quests") local quests = { require("scripts/quests/adoulin/a_certain_substitute_patrolman"), require("scripts/quests/adoulin/the_old_man_and_the_harpoon"), require("scripts/quests/adoulin/fertile_ground"), require("scripts/quests/adoulin/wayward_waypoints") } quests = dsp.quest.involvedQuests(quests)
Be sure to run the table through dsp.quest.involvedQuests()!
Then, when you want to check if a NPC should trigger a quest event and divert control from the rest of the NPC's "default" behavior, just do quests.onEvent(params)
:
function onTrigger(player, npc) if not quests.onTrigger(player, npc) then -- Standard dialogue player:startEvent(535) end end function onEventFinish(player, csid,option) quests.onEventFinish(player, csid, option) end
When called, these event type functions for the involvedQuests object will cause the quest system to go through the quest files, and executes the appropriate individual quest->stage-> NPC logic (the first quest event in a quest file that returns true
).
Also, when one of your quest events fires, the result from your quests.onEvent()
will have returned true. You can use this to prevent further action from the NPC. As seen above, Shipilolo will only start the event for her default dialogue if none of her quest events were triggered.