How to Make a Quest
THIS PAGE IS A DRAFT AND DOES NOT NECESSARILY ACCURATELY DESCRIBE THE BEST WAY TO WRITE A QUEST! EXPERIENCED QUEST-WRITERS SHOULD REVIEW THIS PAGE FOR ACCURACY AND MODIFY IT AS NEEDED!
This is a guide explaining how to script quests for use with DarkStar.
Contents
The Basics
Quests are scripted by modifying the relevant scripts for the NPCs involved in the quests. NPC scripts are written in LUA, but you don't really need to know LUA before you start to successfully script a quest.
The easiest way to learn is by opening up some scripts for NPCs involved in Quests that are already implemented. You can cross reference FFXIclopedia for information about the particular quest you are studying to help you understand better what is going on in the script. I'll walk you through examining a quest.
You may also want to install a text editor with syntax highlighting, such as Notepad++. That will help you by coloring the code in ways that make it easier to read. You can also use Notepad on windows, but it lacks these helpful features.
Studying an Existing Quest
For an example, I'll walk you through examining a fairly simple quest, Lufet's Lake Salt. Go ahead and open that link so you can see the details of the quest.
The first thing you need to know when looking at the code is where the NPC scripts are stored, and how to find the one you need. NPC scripts are stored in scripts/zones/(area_name)/npcs where (area_name) is the zone the NPC you are looking for occupies. Looking at FFXIclopedia, the only related npc for the quest is Nogelle in Port San d'Oria, so we can navigate to scripts/zones/Port_San_dOria/npcs and find the script Nogelle.lua and open it in a text editor.
For simplifying the tutorial, I'll add the NPC script in below for reference:
----------------------------------- -- Area: Port San d'Oria -- NPC: Nogelle -- Starts Lufet's Lake Salt ----------------------------------- package.loaded["scripts/globals/quests"] = nil; require("scripts/globals/quests"); require("scripts/globals/titles"); package.loaded["scripts/zones/Port_San_dOria/TextIDs"] = nil; require("scripts/zones/Port_San_dOria/TextIDs"); ----------------------------------- -- onTrade Action ----------------------------------- function onTrade(player,npc,trade) -- "Flyers for Regine" conditional script FlyerForRegine = player:getQuestStatus(SANDORIA,FLYERS_FOR_REGINE); if (FlyerForRegine == 1) then count = trade:getItemCount(); MagicFlyer = trade:hasItemQty(MagicmartFlyer,1); if (MagicFlyer == true and count == 1) then player:messageSpecial(FLYER_REFUSED); end elseif (player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT) == 1) then count = trade:getItemCount(); LufetSalt = trade:hasItemQty(1019,3); if (LufetSalt == true and count == 3) then player:tradeComplete(); player:addFame(SANDORIA,SAN_FAME*30); player:addGil(GIL_RATE*600); player:setTitle(BEAN_CUISINE_SALTER); player:completeQuest(SANDORIA,LUFET_S_LAKE_SALT); player:startEvent(0x000b); end end end; ----------------------------------- -- onTrigger Action ----------------------------------- function onTrigger(player,npc) LufetsLakeSalt = player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT); if (LufetsLakeSalt == 0) then player:startEvent(0x000c); elseif (LufetsLakeSalt == 1) then player:startEvent(0x000a); elseif (LufetsLakeSalt == 2) then player:startEvent(0x020a); end end; ----------------------------------- -- onEventUpdate ----------------------------------- function onEventUpdate(player,csid,option) --printf("CSID: %u",csid); --printf("RESULT: %u",option); end; ----------------------------------- -- onEventFinish ----------------------------------- function onEventFinish(player,csid,option) --printf("CSID: %u",csid); --printf("RESULT: %u",option); if (csid == 0x000c and option == 1) then player:addQuest(SANDORIA,LUFET_S_LAKE_SALT); elseif (csid == 0x000b) then player:messageSpecial(GIL_OBTAINED,GIL_RATE*600); end end;
Breaking it Down
Now, if you've never done any programming before, this will probably look overwhelming. I'll break down what is going on here.
----------------------------------- -- Area: Port San d'Oria -- NPC: Nogelle -- Starts Lufet's Lake Salt -----------------------------------
In LUA, anything starting with two "hyphens" or "minus signs" is a comment. In other words, nothing on the line is read by the interpreter. We put these in for the benefit of people reading the code behind us. Note that this NPC has a comment "Starts Lufet's Lake Salt". Any time you modify an NPC, you should make a note of which quests you have implemented by adding a line like that one as a comment. It helps others later down the road determine what all the extra code you added in is used for without having to reference another source.
package.loaded["scripts/globals/quests"] = nil; require("scripts/globals/quests"); require("scripts/globals/titles"); package.loaded["scripts/zones/Port_San_dOria/TextIDs"] = nil; require("scripts/zones/Port_San_dOria/TextIDs");
The "package.loaded" lines are used to clean up any leftover data from other scripts. Here we see that we need to clear "quests" and "Port San d'Oria TextIDs" before using them. Exactly which packages need to be cleared and which do not depends on whether data from a previous use can cause problems if loaded into the current quest. In this case, "titles" do not need to be cleared, because it does not contain any volatile data. If you know you need a script, but don't know whether or not to "clear" it first, look at another NPC script that uses it for reference.
The "require" lines are used to load functions and variables for use with the NPC. For this NPC, we are loading "quests" since we are using quest-related functions, "titles" since we need a function to grant a title to the player upon completion of the quest, and "Port San d'Oria TextIDs" which are needed for every scripted NPC in Port San d'Oria, and is how we get our NPCs to speak, by telling the client which TextIDs to display while talking to the NPC.
function onTrade(player,npc,trade) -- "Flyers for Regine" conditional script FlyerForRegine = player:getQuestStatus(SANDORIA,FLYERS_FOR_REGINE); if (FlyerForRegine == 1) then count = trade:getItemCount(); MagicFlyer = trade:hasItemQty(MagicmartFlyer,1); if (MagicFlyer == true and count == 1) then player:messageSpecial(FLYER_REFUSED); end elseif (player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT) == 1) then count = trade:getItemCount(); LufetSalt = trade:hasItemQty(1019,3); if (LufetSalt == true and count == 3) then player:tradeComplete(); player:addFame(SANDORIA,SAN_FAME*30); player:addGil(GIL_RATE*600); player:setTitle(BEAN_CUISINE_SALTER); player:completeQuest(SANDORIA,LUFET_S_LAKE_SALT); player:startEvent(0x000b); end end end;
This is where things get a bit more complicated. What we have above is how the NPC reacts when you attempt to trade an item to them. In this case, there are actually TWO quests that have a triggered response upon trade, one of which being Flyers for Regine and the other being our quest.
Breaking down onTrade
FlyerForRegine = player:getQuestStatus(SANDORIA,FLYERS_FOR_REGINE);
Every time you attempt to trade with Nogelle, this line will check to see if the quest "Flyers for Regine" is active.
if (FlyerForRegine == 1) then count = trade:getItemCount();
If it is, then count how many items have been traded.
MagicFlyer = trade:hasItemQty(MagicmartFlyer,1);
Next, we see if one of the items you traded is at least one "Magicmart Flyer".
if (MagicFlyer == true and count == 1) then player:messageSpecial(FLYER_REFUSED);
Now me ensure that both conditions are true: At least one "Magic Mart Flyer" was traded. Only one item total was traded.
If both conditions are met, then we retrieve a message "FLYER_REFUSED" indicating that this NPC will not take the flyer.
end
We need to finalize our "if" statement so the script knows that the next line is not part of it.
Breaking down onTrade Part 2
Now, we've completed that chunk of the logic, but we still have more to go.
elseif (player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT) == 1) then
Okay, on first glance, this looks good. We've passed the logic for when "Flyers for Regine" is active. "elseif" is used for when the previous condition is not true, though. Wait, then that means we just discovered a bug!
By making this line an elseif, it has made it impossible for this NPC to accept the trade for our quest unless the quest "Flyers for Regine" is NOT even active! This is bad, because our player could have unwittingly picked up both quests, and decided to do Regine's quest later. Our NPC won't accept the trade (or even acknowledge it) for our quest in this case! The correct fix for this would be to make it an "if" statement and ensure that it is impossible for both trades to be done at the same time so both conditions cannot be satisfied, causing a potential conflict in reactions. The previous quest trade is scripted such that only one of a specific item can be traded for the conditions to be satisfied, so we should make sure that is done here too!
For reference, to fix the above line, it should read:
if (player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT) == 1) then
Now, continuing on!
count = trade:getItemCount();
Once again, this gets the total quantity of items traded.
LufetSalt = trade:hasItemQty(1019,3);
We check that at least three "Lufet Salt" are included in the trade.
if (LufetSalt == true and count == 3) then
Now me ensure that both conditions are true: At least three "Lufet Salt" were traded. Only three items total were traded.
player:tradeComplete();
The trade is accepted, the items taken.
player:addFame(SANDORIA,SAN_FAME*30); player:addGil(GIL_RATE*600); player:setTitle(BEAN_CUISINE_SALTER);
Our rewards for this quest: 30 fame added to San d'Oria, 600 gil, and a lovely new title "Bean Cuisine Salter" is set to our player
player:completeQuest(SANDORIA,LUFET_S_LAKE_SALT); player:startEvent(0x000b);
The quest is marked as complete, and a relevant event is triggered.
end end end;
Make sure we close that if! We are completely done with the function onTrade now, so let's go ahead and close the rest out, too. Note how each "end" is indented to match the "if" it goes with, and how each line of code within the "if" statement is indented one step further than the "if". This is done to make the code more readable, and you should follow this practice as well. It helps both the person writing the code and anyone who comes behind them later with reading it.
Breaking down onTrigger
function onTrigger(player,npc) LufetsLakeSalt = player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT); if (LufetsLakeSalt == 0) then player:startEvent(0x000c); elseif (LufetsLakeSalt == 1) then player:startEvent(0x000a); elseif (LufetsLakeSalt == 2) then player:startEvent(0x020a); end end;
onTrigger events activate when you "activate" an NPC (generally by getting close to one and clicking on them, but can also be accomplished by selecting them with "tab" or a controller, and then hitting "enter" or "confirm". We mostly use "events" to progress things when we activate an NPC, so the list of Event IDs may be helpful in finding out which events belong to which NPCs (though the list is currently missing som stuff, it should assist you in most cases).
LufetsLakeSalt = player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT);
Checks to see what the status of the quest "Lufet's Lake Salt" is.
if (LufetsLakeSalt == 0) then player:startEvent(0x000c);
If we have not started the quest, then it plays the event "0x0c" which is the trigger event for the quest. On quests with fame requirements, we should have an additional fame test and even to use when fame is not high enough here.
elseif (LufetsLakeSalt == 1) then player:startEvent(0x000a);
If we have started the quest, but have yet to complete it, event (0x0a) will play instead.
elseif (LufetsLakeSalt == 2) then player:startEvent(0x020a);
After the quest has been completed, if you talk to the npc again, they will trigger event (0x020a)
end end;
I'm sure you know the drill by now.
Note that the event that triggers UPON completion of the quest is not included here. That is because we activated it above with the completion of the successful trade with the code player:startEvent(0x000b);
.