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.
Further 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.
Further 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, cuasing 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
Make sure we close that if!