Difference between revisions of "How to Make a Quest"
Bluekirby0 (Talk | contribs) (Created page with "'''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 I...") |
Bluekirby0 (Talk | contribs) |
||
(5 intermediate revisions by the same user not shown) | |||
Line 1: | Line 1: | ||
'''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 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.''' | '''This is a guide explaining how to script quests for use with DarkStar.''' | ||
== The Basics == | == The Basics == | ||
Line 99: | Line 100: | ||
</pre> | </pre> | ||
− | == Breaking it Down == | + | === 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. | Now, if you've never done any programming before, this will probably look overwhelming. I'll break down what is going on here. | ||
Line 154: | Line 155: | ||
− | === | + | ==== Breaking down onTrade ==== |
<pre> | <pre> | ||
FlyerForRegine = player:getQuestStatus(SANDORIA,FLYERS_FOR_REGINE); | FlyerForRegine = player:getQuestStatus(SANDORIA,FLYERS_FOR_REGINE); | ||
Line 193: | Line 194: | ||
− | === | + | ==== Breaking down onTrade Part 2 ==== |
Now, we've completed that chunk of the logic, but we still have more to go. | Now, we've completed that chunk of the logic, but we still have more to go. | ||
Line 203: | Line 204: | ||
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! | 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, | + | 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! |
+ | |||
+ | Since we are changing the "elseif" to an "if" we also need to close the previous "if". Make sure and add an "end" before the line as well. | ||
For reference, to fix the above line, it should read: | For reference, to fix the above line, it should read: | ||
<pre> | <pre> | ||
+ | end | ||
if (player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT) == 1) then | if (player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT) == 1) then | ||
</pre> | </pre> | ||
Line 256: | Line 260: | ||
<pre> | <pre> | ||
end | end | ||
+ | end | ||
+ | end; | ||
</pre> | </pre> | ||
− | Make sure we close that if! | + | 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 ==== | ||
+ | |||
+ | <pre> | ||
+ | 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; | ||
+ | </pre> | ||
+ | |||
+ | 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). | ||
+ | |||
+ | <pre> | ||
+ | LufetsLakeSalt = player:getQuestStatus(SANDORIA,LUFET_S_LAKE_SALT); | ||
+ | </pre> | ||
+ | |||
+ | Checks to see what the status of the quest "Lufet's Lake Salt" is. | ||
+ | |||
+ | <pre> | ||
+ | if (LufetsLakeSalt == 0) then | ||
+ | player:startEvent(0x000c); | ||
+ | </pre> | ||
+ | |||
+ | 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. | ||
+ | |||
+ | <pre> | ||
+ | elseif (LufetsLakeSalt == 1) then | ||
+ | player:startEvent(0x000a); | ||
+ | </pre> | ||
+ | |||
+ | If we have started the quest, but have yet to complete it, event (0x0a) will play instead. | ||
+ | |||
+ | <pre> | ||
+ | elseif (LufetsLakeSalt == 2) then | ||
+ | player:startEvent(0x020a); | ||
+ | </pre> | ||
+ | |||
+ | After the quest has been completed, if you talk to the npc again, they will trigger event (0x020a) | ||
+ | |||
+ | <pre> | ||
+ | end | ||
+ | end; | ||
+ | </pre> | ||
+ | |||
+ | 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 <code>player:startEvent(0x000b);</code> | ||
+ | |||
+ | ==== Using onEventUpdate ==== | ||
+ | <pre> | ||
+ | function onEventUpdate(player,csid,option) | ||
+ | --printf("CSID: %u",csid); | ||
+ | --printf("RESULT: %u",option); | ||
+ | end; | ||
+ | </pre> | ||
+ | |||
+ | Note that the only code contained in this function is commented out (those two hyphens at the beginning of the line make it a comment). This is because these are used as debugging output to assist in problems with modifying the script, but cause a lot of normally useless junk to display on the server console. If you are testing your quest script on your own server, try un-commenting these lines and see what happens! Do not submit scripts with these lines not commented, though. It causes unnecessary load on the server and makes it harder to find more critical problems. | ||
+ | |||
+ | |||
+ | ==== Breaking down onEventFinish ==== | ||
+ | <pre> | ||
+ | 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; | ||
+ | </pre> | ||
+ | |||
+ | This function triggers at the end of every "event" or cutscene started by the NPC. Let's break it down! | ||
+ | |||
+ | <pre> | ||
+ | --printf("CSID: %u",csid); | ||
+ | --printf("RESULT: %u",option); | ||
+ | </pre> | ||
+ | |||
+ | As above, we have our debugging lines that we can un-comment for quest testing. Let's skip that and move on to the meat of the function. | ||
+ | |||
+ | <pre> | ||
+ | |||
+ | if (csid == 0x000c and option == 1) then | ||
+ | </pre> | ||
+ | |||
+ | Okay, here we see if the cutscene is (0x0c) which should look familiar. That is the cutscene that plays when you talk to Nogelle and you do not have the quest activated. | ||
+ | |||
+ | <pre> | ||
+ | player:addQuest(SANDORIA,LUFET_S_LAKE_SALT); | ||
+ | </pre> | ||
+ | |||
+ | If that is the case, then here we trigger the quest (which will in turn change what event Nogelle will use when talking to her). | ||
+ | |||
+ | <pre> | ||
+ | elseif (csid == 0x000b) then | ||
+ | </pre> | ||
+ | |||
+ | Another familiar looking cutscene! This one is the one that is activated upon a successful trade of 3 Lufet Salts. | ||
+ | |||
+ | <pre> | ||
+ | player:messageSpecial(GIL_OBTAINED,GIL_RATE*600); | ||
+ | </pre> | ||
+ | |||
+ | After that event, we give a message to the player about their gil reward. Note that this does not actually grant gil to the player, as that is done above, BEFORE the cutscene is actually triggered. If it is possible to cancel the event, you may want to consider giving the rewards upon completion of the event here rather than before activating the event. | ||
+ | |||
+ | Also, do not grant a reward, activate an event, and then mark the quest as complete upon completion of the event, or else someone could exploit your quest for the reward as many times as they can satisfy the conditions. It isn't too game-breaking in this case, since you would still need 3 Lufet Salt each time, but for some quests, all you need to do to complete it is to talk to someone. | ||
+ | |||
+ | <pre> | ||
+ | end | ||
+ | end; | ||
+ | </pre> | ||
+ | |||
+ | You get the idea by now... | ||
+ | |||
+ | |||
+ | |||
+ | == Making a Quest from Scratch == | ||
+ | Okay, now we know absolutely everything there is to know about the scripts involved with writing quests! Erm...okay, not really. I, personally, learn by doing, though. We can reference additional implemented [[Quests]] as we find other things we need examples of how to do. That said, let's get to work! First, check [[Quests]] and make sure you choose one that has not been implemented yet. The list is updated daily, so it should be accurate. If you are worried, you can update your local copy via svn and check scripts/globals/quests.lua for bleeding-edge on what is implemented, and check the [http://dspt.freeforums.org/scripting-f12.html Scripting] section on the forums to see if anyone has submitted the particular quest recently. In my case, I'm going to choose [http://wiki.ffxiclopedia.org/wiki/The_Rumor "The Rumor"] as my first quest! | ||
+ | |||
+ | The quest is started by Novalmauge in Bostaunieux Oubliette, so we find his script in scripts/zones/Bostaunieux_Oubliette/npcs/Novalmauge.lua | ||
+ | |||
+ | Inside we find: | ||
+ | |||
+ | <pre> | ||
+ | ----------------------------------- | ||
+ | -- Area: Bostaunieux Obliette | ||
+ | -- NPC: Novalmauge | ||
+ | -- Involved in Quest: The Holy Crest, Trouble at the Sluice | ||
+ | -- @zone 167 | ||
+ | -- @pos 70 -24 21 | ||
+ | ----------------------------------- | ||
+ | package.loaded["scripts/zones/Bostaunieux_Oubliette/TextIDs"] = nil; | ||
+ | ----------------------------------- | ||
+ | |||
+ | require("scripts/globals/settings"); | ||
+ | require("scripts/globals/titles"); | ||
+ | require("scripts/globals/keyitems"); | ||
+ | require("scripts/globals/shop"); | ||
+ | require("scripts/globals/quests"); | ||
+ | require("scripts/zones/Bostaunieux_Oubliette/TextIDs"); | ||
+ | |||
+ | ----------------------------------- | ||
+ | -- onTrade Action | ||
+ | ----------------------------------- | ||
+ | |||
+ | function onTrade(player,npc,trade) | ||
+ | |||
+ | if(player:getVar("troubleAtTheSluiceVar") == 2) then | ||
+ | if(trade:hasItemQty(959,1) and trade:getItemCount() == 1) then -- Trade Dahlia | ||
+ | player:startEvent(0x0011); | ||
+ | end | ||
+ | end | ||
+ | end; | ||
+ | |||
+ | ----------------------------------- | ||
+ | -- onTrigger Action | ||
+ | ----------------------------------- | ||
+ | |||
+ | function onTrigger(player,npc) | ||
+ | |||
+ | troubleAtTheSluice = player:getQuestStatus(SANDORIA,TROUBLE_AT_THE_SLUICE); | ||
+ | TheHolyCrest = player:getVar("TheHolyCrest_Event"); | ||
+ | tatsVar = player:getVar("troubleAtTheSluiceVar"); | ||
+ | |||
+ | -- The Holy Crest Quest | ||
+ | if(TheHolyCrest == 1) then | ||
+ | player:startEvent(0x0006); | ||
+ | elseif(TheHolyCrest == 2) then | ||
+ | player:startEvent(0x0007); | ||
+ | -- Trouble at the Sluice Quest | ||
+ | elseif(tatsVar == 1) then | ||
+ | player:startEvent(0x000f); | ||
+ | player:setVar("troubleAtTheSluiceVar",2); | ||
+ | elseif(tatsVar == 2) then | ||
+ | player:startEvent(0x0010); | ||
+ | else | ||
+ | player:startEvent(0x000a); -- Standard dialog | ||
+ | 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 == 0x0006) then | ||
+ | player:setVar("TheHolyCrest_Event",2); | ||
+ | elseif(csid == 0x0011) then | ||
+ | player:tradeComplete(); | ||
+ | player:addKeyItem(NEUTRALIZER); | ||
+ | player:messageSpecial(KEYITEM_OBTAINED,NEUTRALIZER); | ||
+ | player:setVar("troubleAtTheSluiceVar",0); | ||
+ | end | ||
+ | |||
+ | end; | ||
+ | </pre> | ||
+ | |||
+ | === Modifying the Header Comments === | ||
+ | Okay, so first we want to make a note of the quest we are addding to the NPC. In the header comments add the line: | ||
+ | |||
+ | <pre>-- Starts and Finishes Quest: The Rumor</pre> | ||
+ | |||
+ | So someone else will understand why you added the code in later. | ||
+ | |||
+ | === Adjusting Includes === | ||
+ | <pre> | ||
+ | package.loaded["scripts/zones/Bostaunieux_Oubliette/TextIDs"] = nil; | ||
+ | ----------------------------------- | ||
+ | |||
+ | require("scripts/globals/settings"); | ||
+ | require("scripts/globals/titles"); | ||
+ | require("scripts/globals/keyitems"); | ||
+ | require("scripts/globals/shop"); | ||
+ | require("scripts/globals/quests"); | ||
+ | require("scripts/zones/Bostaunieux_Oubliette/TextIDs"); | ||
+ | </pre> | ||
+ | |||
+ | It is laid out a little differently than our example quest, but it does the same thing. All of our necessary requirements are already loaded, so we don't need to change anything here. | ||
+ | |||
+ | === Tweaking onTrade === | ||
+ | Let's take a look at FFXIclopedia again. See if the quest involves a trade at any point in time. We see the step "Trade Novalmauge a vial of Beastman Blood." on the quest, so we have work to do. | ||
+ | |||
+ | First, let's look up "Beastman Blood" in [[Basic Item IDs]]. It looks like we have a value of "930", so keep track of that number. Our existing onTrade code is as follows: | ||
+ | |||
+ | <pre> | ||
+ | if(player:getVar("troubleAtTheSluiceVar") == 2) then | ||
+ | if(trade:hasItemQty(959,1) and trade:getItemCount() == 1) then -- Trade Dahlia | ||
+ | player:startEvent(0x0011); | ||
+ | end | ||
+ | end | ||
+ | </pre> | ||
+ | |||
+ | Which is a trade involved with a separate quest. We can add in our own code either above or below this, but make sure you don't accidentally put it between one of those two ifs and its matching end statement. Now let's start adding logic! Borrowing from our previous example, we can work out the following: | ||
+ | |||
+ | <pre> | ||
+ | if(player:getQuestStatus(SANDORIA,THE_RUMOR) == QUEST_ACCEPTED) then | ||
+ | </pre> | ||
+ | |||
+ | We want to make sure the trade is only processed if we have started the appropriate quest. Note that QUEST_ACCEPTED is an alias for "1" and means the same thing. We'll use QUEST_ACCEPTED to make it more obvious to anyone that goes behind us and reads it. | ||
+ | |||
+ | <pre> | ||
+ | count = trade:getItemCount(); | ||
+ | </pre> | ||
+ | |||
+ | Like in the previous scenario, we want to make sure someone can't trade and lose items unrelated to the quest, so we will keep track of the total count of items to check later. | ||
+ | |||
+ | <pre> | ||
+ | BeastBlood = trade:hasItemQty(930,1) | ||
+ | </pre> | ||
+ | |||
+ | Remember that number I told you to remember? Here it is! That is how we check exactly what item is being traded. | ||
+ | |||
+ | Make sure we have at least one beastman blood. Note that "BeastBlood" above can be nearly anything you want as long as you use the same name later. It is best to make it obvious what it is used for to keep the script readable. It will actually store a returned true/false value from the trade:hasItemQty function. | ||
+ | |||
+ | <pre> | ||
+ | if(BeastBlood == true and count == 1) then | ||
+ | </pre> | ||
+ | |||
+ | Make sure we satisfy both conditions that: | ||
+ | At least one beastman blood item is being traded. | ||
+ | Exactly one item is being traded. | ||
+ | |||
+ | If we don't do this, people could accidentally trade additional items and lost them permanently. | ||
+ | |||
+ | |||
+ | <pre> | ||
+ | player:startEvent(0x000c); | ||
+ | </pre> | ||
+ | |||
+ | We will play the cutscene that is supposed to activate upon completion of the quest. I am not going to activate the rewards in this section as I prefer to give them at the end of the cutscene to avoid potential exploits, so let's close up this section. | ||
+ | |||
+ | <pre> | ||
+ | end | ||
+ | end | ||
+ | </pre> | ||
+ | |||
+ | |||
+ | |||
+ | === Tweaking onTrigger === | ||
+ | Okay, our existing onTrigger events look like this: | ||
+ | |||
+ | <pre> | ||
+ | troubleAtTheSluice = player:getQuestStatus(SANDORIA,TROUBLE_AT_THE_SLUICE); | ||
+ | TheHolyCrest = player:getVar("TheHolyCrest_Event"); | ||
+ | tatsVar = player:getVar("troubleAtTheSluiceVar"); | ||
+ | |||
+ | -- The Holy Crest Quest | ||
+ | if(TheHolyCrest == 1) then | ||
+ | player:startEvent(0x0006); | ||
+ | elseif(TheHolyCrest == 2) then | ||
+ | player:startEvent(0x0007); | ||
+ | -- Trouble at the Sluice Quest | ||
+ | elseif(tatsVar == 1) then | ||
+ | player:startEvent(0x000f); | ||
+ | player:setVar("troubleAtTheSluiceVar",2); | ||
+ | elseif(tatsVar == 2) then | ||
+ | player:startEvent(0x0010); | ||
+ | else | ||
+ | player:startEvent(0x000a); -- Standard dialog | ||
+ | end | ||
+ | </pre> | ||
+ | |||
+ | Alright, let's get to work! | ||
+ | |||
+ | <pre> | ||
+ | theRumor = player:getQuestStatus(SANDORIA,THE_RUMOR); | ||
+ | </pre> | ||
+ | |||
+ | The first thing we want to do is add in a check to see if the quest is active before determining whether to activate the relevant event. We do that by setting a variable that works similarly to the one we used in onTrade to make sure the right item and quantity were being traded. It will store a quest stage value so it will be easy to evaluate later. This should be added at the top of the function with the similar variables. | ||
+ | |||
+ | <pre> | ||
+ | -- The rumor Quest | ||
+ | </pre> | ||
+ | |||
+ | The previous person was kind enough to leave comments as for what triggers go with which quest, so let's follow suit. We will add this line to the start of our section of code. Since we probably only want to activate this quest if we are not in the middle of another quest involving Novalmauge, let's put this after all the other events, but before the "else" statement (which triggers standard dialogue. Take another look at the requirements for triggering this quest on FFXIclopedia before continuing. | ||
+ | |||
+ | <pre> | ||
+ | elseif(theRumor == QUEST_AVAILABLE and player:getFameLevel(SANDORIA) >= 3 and player:getMainLvl() >= 10) then | ||
+ | </pre> | ||
+ | |||
+ | Here we have a different situation from our previous example. This quest has a fame requirement listed as "San d'Oria Reputation 3" on FFXIclopedia and a level requirement of 10. Above you can see how to use the player:getFameLevel() function to check for fame requirements, as well as player:getMainLvl() to check job level. Make sure you always use >= and not == for fame and level checks since they are always minimum requirements. | ||
+ | |||
+ | ==== Finding the Right Cutscene/Event ==== | ||
+ | Now we need to find out [[Bostaunieux Oubliette Event IDs]] for Novalmauge and test them to see which is the right one to continue. We get 0-7 and 10-17 as potential possibilities. The best way to proceed is to login on a server you are a GM on (see [[Make My Character a GM]] to do this on a private server). Check [[Zone IDs]] for Bostaunieux Oubliette and then log into the game on a GM character. | ||
+ | |||
+ | Once in the game, hit the space bar to open your text entry box and type in: | ||
+ | |||
+ | <code>@zone 167</code> | ||
+ | |||
+ | to be taken to Bostaunieux Oubliette. If you use this and it takes you into a horrible, monster-infested part of the zone, remember that nothing is aggressive to a GM. Anyway, to continue...it doesn't matter where in the zone you are, just that you are in the right zone. TextIDs are loaded per zone, so you must be here to find the right cutscene, but exactly WHERE in the zone is unimportant. At this point you can start testing those ranges for the proper cutscene. It can be tedious and time consuming, but it is currently the only way of finding the scene you want. | ||
+ | |||
+ | <code>@cs 0</code> | ||
+ | |||
+ | on your GM character will now play a cutscene...this one is about a cursed scythe, so its not the one we are looking for. | ||
+ | |||
+ | After going through the events one at a time (you might want to make notes of which cutscenes are related to the quest, but are not used to start it), I eventually find "13" is the code needed for the cutscene to start the quest. Let's [http://www.binaryhexconverter.com/decimal-to-hex-converter convert that to hexadecimal] and we get "d", which is what we need for our event trigger! | ||
+ | |||
+ | <pre> | ||
+ | player:startEvent(0x000d); | ||
+ | </pre> | ||
+ | |||
+ | Here is how we activate that event. Now, if the quest is already started, we will get a different response. If you took good notes, you will remember that 11 and 12 had something that seemed related. Take another look at that one, and...yep...sounds like 11 it fits! | ||
+ | |||
+ | <pre> | ||
+ | elseif(theRumor == QUEST_ACCEPTED) then | ||
+ | player:startEvent(0x000b); | ||
+ | </pre> | ||
+ | |||
+ | Now, there is one more event that is related. If you go through the rest of Novalmauge's events, you will see event 14. It isn't very obvious that it is related, but this is his standard dialogue once you have completed the quest. You will want to add another snippet that modifies his standard dialogue if this quest is complete. | ||
+ | |||
+ | <pre> | ||
+ | elseif(theRumor == QUEST_COMPLETED) then | ||
+ | player:startEvent(0x000e); | ||
+ | </pre> | ||
+ | |||
+ | This will ensure any time you talk to him after completing this quest, and not while on another quest related to him, that he will always give you this new dialogue. | ||
+ | |||
+ | === onEventUpdate === | ||
+ | <pre> | ||
+ | function onEventUpdate(player,csid,option) | ||
+ | --printf("CSID: %u",csid); | ||
+ | --printf("RESULT: %u",option); | ||
+ | end; | ||
+ | </pre> | ||
+ | |||
+ | If you find it useful on your own test server, you can un-comment these to get more server-side output whenever you trigger an event. | ||
+ | |||
+ | === onEventFinish === | ||
+ | |||
+ | Now to modify what happens at the end of a cutscene. You may want to take this opportunity to refresh yourself on the rewards of the quest and at what stage you get them if there are multiple. For this quest, we have a "Scroll of Drain" rewarded upon trading the "beastman blood" to Novalmauge. What we already have: | ||
+ | |||
+ | <pre> | ||
+ | if(csid == 0x0006) then | ||
+ | player:setVar("TheHolyCrest_Event",2); | ||
+ | elseif(csid == 0x0011) then | ||
+ | player:tradeComplete(); | ||
+ | player:addKeyItem(NEUTRALIZER); | ||
+ | player:messageSpecial(KEYITEM_OBTAINED,NEUTRALIZER); | ||
+ | player:setVar("troubleAtTheSluiceVar",0); | ||
+ | end | ||
+ | </pre> | ||
+ | |||
+ | The events we have added are (0x0c) on trading the blood, (0x0d) on quest start, and (0x0b) when speaking to Novalmauge after the quest is activated. We also have (0x0e) that we change his standard dialogue to after completion of the quest, but we don't need to attach anything to that event or (0x0b). First, let's deal with flagging the quest as activated. | ||
+ | |||
+ | <pre> | ||
+ | elseif(csid == 0x000d and option == 1) then | ||
+ | player:addQuest(SANDORIA,THE_RUMOR); | ||
+ | </pre> | ||
+ | |||
+ | Add this after the last "elseif" and right before its matching "end". Notice the option == 1 here, which specifies that the player chose to accept the quest in the dialogue. This flags the quest as started. | ||
+ | |||
+ | <pre> | ||
+ | elseif(csid == 0x000c) then | ||
+ | </pre> | ||
+ | |||
+ | Specify what happens upon the conpletion of the trade event. | ||
+ | |||
+ | <pre> | ||
+ | if (player:getFreeSlotsCount() == 0) then | ||
+ | player:messageSpecial(ITEM_CANNOT_BE_OBTAINED,4853); -- Scroll of Drain | ||
+ | </pre> | ||
+ | |||
+ | Since this quest gives an item reward, we want to put this check in first. It will handle the case where a player cannot get the reward because they have no room in their inventory, and prevent them from being unable to get the reward later by bypassing the completion of the quest until they make room. The second line gives the player the message telling them that their inventory is full. | ||
+ | |||
+ | <pre> | ||
+ | else | ||
+ | player:tradeComplete(); | ||
+ | </pre> | ||
+ | |||
+ | If everything is ducky (completed the cutscene, have room in your bag), go ahead and let him take the trade. You will lose the item at this point. | ||
+ | |||
+ | <pre> | ||
+ | player:addItem(4853); | ||
+ | player:messageSpecial(ITEM_OBTAINED, 4853); -- Scroll of Drain | ||
+ | </pre> | ||
+ | |||
+ | Give the player the "Scroll of Drain" as a reward, and display a message telling them they received it. | ||
+ | |||
+ | |||
+ | <pre> | ||
+ | player:addFame(SANDORIA,SAN_FAME*30); | ||
+ | </pre> | ||
+ | |||
+ | Add 30 fame as part of the reward (seems to be a standard value for completing a quest, so we will go with that). | ||
+ | |||
+ | <pre> | ||
+ | player:completeQuest(SANDORIA,THE_RUMOR); | ||
+ | end | ||
+ | </pre> | ||
+ | |||
+ | Flag the quest as completed. and close the "if". | ||
+ | |||
+ | That brings us to the end of our changes. Our complete, updated script is as follows: | ||
+ | |||
+ | <pre> | ||
+ | ----------------------------------- | ||
+ | -- Area: Bostaunieux Obliette | ||
+ | -- NPC: Novalmauge | ||
+ | -- Starts and Finishes Quest: The Rumor | ||
+ | -- Involved in Quest: The Holy Crest, Trouble at the Sluice | ||
+ | -- @zone 167 | ||
+ | -- @pos 70 -24 21 | ||
+ | ----------------------------------- | ||
+ | package.loaded["scripts/zones/Bostaunieux_Oubliette/TextIDs"] = nil; | ||
+ | ----------------------------------- | ||
+ | |||
+ | require("scripts/globals/settings"); | ||
+ | require("scripts/globals/titles"); | ||
+ | require("scripts/globals/keyitems"); | ||
+ | require("scripts/globals/shop"); | ||
+ | require("scripts/globals/quests"); | ||
+ | require("scripts/zones/Bostaunieux_Oubliette/TextIDs"); | ||
+ | |||
+ | ----------------------------------- | ||
+ | -- onTrade Action | ||
+ | ----------------------------------- | ||
+ | |||
+ | function onTrade(player,npc,trade) | ||
+ | |||
+ | if(player:getVar("troubleAtTheSluiceVar") == 2) then | ||
+ | if(trade:hasItemQty(959,1) and trade:getItemCount() == 1) then -- Trade Dahlia | ||
+ | player:startEvent(0x0011); | ||
+ | end | ||
+ | end | ||
+ | if(player:getQuestStatus(SANDORIA,THE_RUMOR) == QUEST_ACCEPTED) then | ||
+ | count = trade:getItemCount(); | ||
+ | BeastBlood = trade:hasItemQty(930,1) | ||
+ | if(BeastBlood == true and count == 1) then | ||
+ | player:startEvent(0x000c); | ||
+ | end | ||
+ | end | ||
+ | end; | ||
+ | |||
+ | ----------------------------------- | ||
+ | -- onTrigger Action | ||
+ | ----------------------------------- | ||
+ | |||
+ | function onTrigger(player,npc) | ||
+ | |||
+ | troubleAtTheSluice = player:getQuestStatus(SANDORIA,TROUBLE_AT_THE_SLUICE); | ||
+ | TheHolyCrest = player:getVar("TheHolyCrest_Event"); | ||
+ | tatsVar = player:getVar("troubleAtTheSluiceVar"); | ||
+ | theRumor = player:getQuestStatus(SANDORIA,THE_RUMOR); | ||
+ | |||
+ | -- The Holy Crest Quest | ||
+ | if(TheHolyCrest == 1) then | ||
+ | player:startEvent(0x0006); | ||
+ | elseif(TheHolyCrest == 2) then | ||
+ | player:startEvent(0x0007); | ||
+ | -- Trouble at the Sluice Quest | ||
+ | elseif(tatsVar == 1) then | ||
+ | player:startEvent(0x000f); | ||
+ | player:setVar("troubleAtTheSluiceVar",2); | ||
+ | elseif(tatsVar == 2) then | ||
+ | player:startEvent(0x0010); | ||
+ | -- The rumor Quest | ||
+ | elseif(theRumor == QUEST_AVAILABLE and player:getFameLevel(SANDORIA) >= 3 and player:getMainLvl() >= 10) then | ||
+ | player:startEvent(0x000d); | ||
+ | elseif(theRumor == QUEST_ACCEPTED) then | ||
+ | player:startEvent(0x000b); | ||
+ | elseif(theRumor == QUEST_COMPLETED) then | ||
+ | player:startEvent(0x000e); -- Standard dialog after "The Rumor" | ||
+ | else | ||
+ | player:startEvent(0x000a); -- Standard dialog | ||
+ | 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 == 0x0006) then | ||
+ | player:setVar("TheHolyCrest_Event",2); | ||
+ | elseif(csid == 0x0011) then | ||
+ | player:tradeComplete(); | ||
+ | player:addKeyItem(NEUTRALIZER); | ||
+ | player:messageSpecial(KEYITEM_OBTAINED,NEUTRALIZER); | ||
+ | player:setVar("troubleAtTheSluiceVar",0); | ||
+ | elseif(csid == 0x000d and option == 1) then | ||
+ | player:addQuest(SANDORIA,THE_RUMOR); | ||
+ | elseif(csid == 0x000c) then | ||
+ | if (player:getFreeSlotsCount() == 0) then | ||
+ | player:messageSpecial(ITEM_CANNOT_BE_OBTAINED,4853); -- Scroll of Drain | ||
+ | else | ||
+ | player:tradeComplete(); | ||
+ | player:addItem(4853); | ||
+ | player:messageSpecial(ITEM_OBTAINED, 4853); -- Scroll of Drain | ||
+ | |||
+ | player:addFame(SANDORIA,SAN_FAME*30); | ||
+ | player:completeQuest(SANDORIA,THE_RUMOR); | ||
+ | end | ||
+ | end | ||
+ | |||
+ | end; | ||
+ | </pre> | ||
+ | |||
+ | == Testing Your New Quest == | ||
+ | Alright, the next thing you should do before sending off your quest is to test it on your own server. Save the modified script and replace scripts/zones/Bostaunieux_Oubliette/npcs/Novalmauge.lua with your new version. You can make a backup first, or use the svn revert feature to restore it to the current version later. | ||
+ | |||
+ | Make sure you can satisfy the requirements of the quest. Fire up your server and head to Bostaunieux Oubliette. Try and follow the quest as detailed on FFXIclopedia and make sure everything goes as planned. You should go out of your way to test things you aren't too sure about, as well. | ||
+ | |||
+ | I ran through this quest, and managed to complete it perfectly. It flagged properly in my journal, was marked as completed, and Novalmauge's default dialogue has now changed. Everything went according to plan! | ||
+ | |||
+ | Once you are satisfied with your quest script, start a new topic in the [http://dspt.freeforums.org/scripting-f12.html Scripting] section on the dsp forums. Use the name of the quest as the topic, and make sure you attach the quest script! Someone with subversion access will test the script again and commit it if they do not find any problems. Check back with the topic until it has been committed in case you need to add anything else! |
Latest revision as of 07:22, 6 April 2012
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!
Since we are changing the "elseif" to an "if" we also need to close the previous "if". Make sure and add an "end" before the line as well.
For reference, to fix the above line, it should read:
end 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);
Using onEventUpdate
function onEventUpdate(player,csid,option) --printf("CSID: %u",csid); --printf("RESULT: %u",option); end;
Note that the only code contained in this function is commented out (those two hyphens at the beginning of the line make it a comment). This is because these are used as debugging output to assist in problems with modifying the script, but cause a lot of normally useless junk to display on the server console. If you are testing your quest script on your own server, try un-commenting these lines and see what happens! Do not submit scripts with these lines not commented, though. It causes unnecessary load on the server and makes it harder to find more critical problems.
Breaking down 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;
This function triggers at the end of every "event" or cutscene started by the NPC. Let's break it down!
--printf("CSID: %u",csid); --printf("RESULT: %u",option);
As above, we have our debugging lines that we can un-comment for quest testing. Let's skip that and move on to the meat of the function.
if (csid == 0x000c and option == 1) then
Okay, here we see if the cutscene is (0x0c) which should look familiar. That is the cutscene that plays when you talk to Nogelle and you do not have the quest activated.
player:addQuest(SANDORIA,LUFET_S_LAKE_SALT);
If that is the case, then here we trigger the quest (which will in turn change what event Nogelle will use when talking to her).
elseif (csid == 0x000b) then
Another familiar looking cutscene! This one is the one that is activated upon a successful trade of 3 Lufet Salts.
player:messageSpecial(GIL_OBTAINED,GIL_RATE*600);
After that event, we give a message to the player about their gil reward. Note that this does not actually grant gil to the player, as that is done above, BEFORE the cutscene is actually triggered. If it is possible to cancel the event, you may want to consider giving the rewards upon completion of the event here rather than before activating the event.
Also, do not grant a reward, activate an event, and then mark the quest as complete upon completion of the event, or else someone could exploit your quest for the reward as many times as they can satisfy the conditions. It isn't too game-breaking in this case, since you would still need 3 Lufet Salt each time, but for some quests, all you need to do to complete it is to talk to someone.
end end;
You get the idea by now...
Making a Quest from Scratch
Okay, now we know absolutely everything there is to know about the scripts involved with writing quests! Erm...okay, not really. I, personally, learn by doing, though. We can reference additional implemented Quests as we find other things we need examples of how to do. That said, let's get to work! First, check Quests and make sure you choose one that has not been implemented yet. The list is updated daily, so it should be accurate. If you are worried, you can update your local copy via svn and check scripts/globals/quests.lua for bleeding-edge on what is implemented, and check the Scripting section on the forums to see if anyone has submitted the particular quest recently. In my case, I'm going to choose "The Rumor" as my first quest!
The quest is started by Novalmauge in Bostaunieux Oubliette, so we find his script in scripts/zones/Bostaunieux_Oubliette/npcs/Novalmauge.lua
Inside we find:
----------------------------------- -- Area: Bostaunieux Obliette -- NPC: Novalmauge -- Involved in Quest: The Holy Crest, Trouble at the Sluice -- @zone 167 -- @pos 70 -24 21 ----------------------------------- package.loaded["scripts/zones/Bostaunieux_Oubliette/TextIDs"] = nil; ----------------------------------- require("scripts/globals/settings"); require("scripts/globals/titles"); require("scripts/globals/keyitems"); require("scripts/globals/shop"); require("scripts/globals/quests"); require("scripts/zones/Bostaunieux_Oubliette/TextIDs"); ----------------------------------- -- onTrade Action ----------------------------------- function onTrade(player,npc,trade) if(player:getVar("troubleAtTheSluiceVar") == 2) then if(trade:hasItemQty(959,1) and trade:getItemCount() == 1) then -- Trade Dahlia player:startEvent(0x0011); end end end; ----------------------------------- -- onTrigger Action ----------------------------------- function onTrigger(player,npc) troubleAtTheSluice = player:getQuestStatus(SANDORIA,TROUBLE_AT_THE_SLUICE); TheHolyCrest = player:getVar("TheHolyCrest_Event"); tatsVar = player:getVar("troubleAtTheSluiceVar"); -- The Holy Crest Quest if(TheHolyCrest == 1) then player:startEvent(0x0006); elseif(TheHolyCrest == 2) then player:startEvent(0x0007); -- Trouble at the Sluice Quest elseif(tatsVar == 1) then player:startEvent(0x000f); player:setVar("troubleAtTheSluiceVar",2); elseif(tatsVar == 2) then player:startEvent(0x0010); else player:startEvent(0x000a); -- Standard dialog 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 == 0x0006) then player:setVar("TheHolyCrest_Event",2); elseif(csid == 0x0011) then player:tradeComplete(); player:addKeyItem(NEUTRALIZER); player:messageSpecial(KEYITEM_OBTAINED,NEUTRALIZER); player:setVar("troubleAtTheSluiceVar",0); end end;
Modifying the Header Comments
Okay, so first we want to make a note of the quest we are addding to the NPC. In the header comments add the line:
-- Starts and Finishes Quest: The Rumor
So someone else will understand why you added the code in later.
Adjusting Includes
package.loaded["scripts/zones/Bostaunieux_Oubliette/TextIDs"] = nil; ----------------------------------- require("scripts/globals/settings"); require("scripts/globals/titles"); require("scripts/globals/keyitems"); require("scripts/globals/shop"); require("scripts/globals/quests"); require("scripts/zones/Bostaunieux_Oubliette/TextIDs");
It is laid out a little differently than our example quest, but it does the same thing. All of our necessary requirements are already loaded, so we don't need to change anything here.
Tweaking onTrade
Let's take a look at FFXIclopedia again. See if the quest involves a trade at any point in time. We see the step "Trade Novalmauge a vial of Beastman Blood." on the quest, so we have work to do.
First, let's look up "Beastman Blood" in Basic Item IDs. It looks like we have a value of "930", so keep track of that number. Our existing onTrade code is as follows:
if(player:getVar("troubleAtTheSluiceVar") == 2) then if(trade:hasItemQty(959,1) and trade:getItemCount() == 1) then -- Trade Dahlia player:startEvent(0x0011); end end
Which is a trade involved with a separate quest. We can add in our own code either above or below this, but make sure you don't accidentally put it between one of those two ifs and its matching end statement. Now let's start adding logic! Borrowing from our previous example, we can work out the following:
if(player:getQuestStatus(SANDORIA,THE_RUMOR) == QUEST_ACCEPTED) then
We want to make sure the trade is only processed if we have started the appropriate quest. Note that QUEST_ACCEPTED is an alias for "1" and means the same thing. We'll use QUEST_ACCEPTED to make it more obvious to anyone that goes behind us and reads it.
count = trade:getItemCount();
Like in the previous scenario, we want to make sure someone can't trade and lose items unrelated to the quest, so we will keep track of the total count of items to check later.
BeastBlood = trade:hasItemQty(930,1)
Remember that number I told you to remember? Here it is! That is how we check exactly what item is being traded.
Make sure we have at least one beastman blood. Note that "BeastBlood" above can be nearly anything you want as long as you use the same name later. It is best to make it obvious what it is used for to keep the script readable. It will actually store a returned true/false value from the trade:hasItemQty function.
if(BeastBlood == true and count == 1) then
Make sure we satisfy both conditions that: At least one beastman blood item is being traded. Exactly one item is being traded.
If we don't do this, people could accidentally trade additional items and lost them permanently.
player:startEvent(0x000c);
We will play the cutscene that is supposed to activate upon completion of the quest. I am not going to activate the rewards in this section as I prefer to give them at the end of the cutscene to avoid potential exploits, so let's close up this section.
end end
Tweaking onTrigger
Okay, our existing onTrigger events look like this:
troubleAtTheSluice = player:getQuestStatus(SANDORIA,TROUBLE_AT_THE_SLUICE); TheHolyCrest = player:getVar("TheHolyCrest_Event"); tatsVar = player:getVar("troubleAtTheSluiceVar"); -- The Holy Crest Quest if(TheHolyCrest == 1) then player:startEvent(0x0006); elseif(TheHolyCrest == 2) then player:startEvent(0x0007); -- Trouble at the Sluice Quest elseif(tatsVar == 1) then player:startEvent(0x000f); player:setVar("troubleAtTheSluiceVar",2); elseif(tatsVar == 2) then player:startEvent(0x0010); else player:startEvent(0x000a); -- Standard dialog end
Alright, let's get to work!
theRumor = player:getQuestStatus(SANDORIA,THE_RUMOR);
The first thing we want to do is add in a check to see if the quest is active before determining whether to activate the relevant event. We do that by setting a variable that works similarly to the one we used in onTrade to make sure the right item and quantity were being traded. It will store a quest stage value so it will be easy to evaluate later. This should be added at the top of the function with the similar variables.
-- The rumor Quest
The previous person was kind enough to leave comments as for what triggers go with which quest, so let's follow suit. We will add this line to the start of our section of code. Since we probably only want to activate this quest if we are not in the middle of another quest involving Novalmauge, let's put this after all the other events, but before the "else" statement (which triggers standard dialogue. Take another look at the requirements for triggering this quest on FFXIclopedia before continuing.
elseif(theRumor == QUEST_AVAILABLE and player:getFameLevel(SANDORIA) >= 3 and player:getMainLvl() >= 10) then
Here we have a different situation from our previous example. This quest has a fame requirement listed as "San d'Oria Reputation 3" on FFXIclopedia and a level requirement of 10. Above you can see how to use the player:getFameLevel() function to check for fame requirements, as well as player:getMainLvl() to check job level. Make sure you always use >= and not == for fame and level checks since they are always minimum requirements.
Finding the Right Cutscene/Event
Now we need to find out Bostaunieux Oubliette Event IDs for Novalmauge and test them to see which is the right one to continue. We get 0-7 and 10-17 as potential possibilities. The best way to proceed is to login on a server you are a GM on (see Make My Character a GM to do this on a private server). Check Zone IDs for Bostaunieux Oubliette and then log into the game on a GM character.
Once in the game, hit the space bar to open your text entry box and type in:
@zone 167
to be taken to Bostaunieux Oubliette. If you use this and it takes you into a horrible, monster-infested part of the zone, remember that nothing is aggressive to a GM. Anyway, to continue...it doesn't matter where in the zone you are, just that you are in the right zone. TextIDs are loaded per zone, so you must be here to find the right cutscene, but exactly WHERE in the zone is unimportant. At this point you can start testing those ranges for the proper cutscene. It can be tedious and time consuming, but it is currently the only way of finding the scene you want.
@cs 0
on your GM character will now play a cutscene...this one is about a cursed scythe, so its not the one we are looking for.
After going through the events one at a time (you might want to make notes of which cutscenes are related to the quest, but are not used to start it), I eventually find "13" is the code needed for the cutscene to start the quest. Let's convert that to hexadecimal and we get "d", which is what we need for our event trigger!
player:startEvent(0x000d);
Here is how we activate that event. Now, if the quest is already started, we will get a different response. If you took good notes, you will remember that 11 and 12 had something that seemed related. Take another look at that one, and...yep...sounds like 11 it fits!
elseif(theRumor == QUEST_ACCEPTED) then player:startEvent(0x000b);
Now, there is one more event that is related. If you go through the rest of Novalmauge's events, you will see event 14. It isn't very obvious that it is related, but this is his standard dialogue once you have completed the quest. You will want to add another snippet that modifies his standard dialogue if this quest is complete.
elseif(theRumor == QUEST_COMPLETED) then player:startEvent(0x000e);
This will ensure any time you talk to him after completing this quest, and not while on another quest related to him, that he will always give you this new dialogue.
onEventUpdate
function onEventUpdate(player,csid,option) --printf("CSID: %u",csid); --printf("RESULT: %u",option); end;
If you find it useful on your own test server, you can un-comment these to get more server-side output whenever you trigger an event.
onEventFinish
Now to modify what happens at the end of a cutscene. You may want to take this opportunity to refresh yourself on the rewards of the quest and at what stage you get them if there are multiple. For this quest, we have a "Scroll of Drain" rewarded upon trading the "beastman blood" to Novalmauge. What we already have:
if(csid == 0x0006) then player:setVar("TheHolyCrest_Event",2); elseif(csid == 0x0011) then player:tradeComplete(); player:addKeyItem(NEUTRALIZER); player:messageSpecial(KEYITEM_OBTAINED,NEUTRALIZER); player:setVar("troubleAtTheSluiceVar",0); end
The events we have added are (0x0c) on trading the blood, (0x0d) on quest start, and (0x0b) when speaking to Novalmauge after the quest is activated. We also have (0x0e) that we change his standard dialogue to after completion of the quest, but we don't need to attach anything to that event or (0x0b). First, let's deal with flagging the quest as activated.
elseif(csid == 0x000d and option == 1) then player:addQuest(SANDORIA,THE_RUMOR);
Add this after the last "elseif" and right before its matching "end". Notice the option == 1 here, which specifies that the player chose to accept the quest in the dialogue. This flags the quest as started.
elseif(csid == 0x000c) then
Specify what happens upon the conpletion of the trade event.
if (player:getFreeSlotsCount() == 0) then player:messageSpecial(ITEM_CANNOT_BE_OBTAINED,4853); -- Scroll of Drain
Since this quest gives an item reward, we want to put this check in first. It will handle the case where a player cannot get the reward because they have no room in their inventory, and prevent them from being unable to get the reward later by bypassing the completion of the quest until they make room. The second line gives the player the message telling them that their inventory is full.
else player:tradeComplete();
If everything is ducky (completed the cutscene, have room in your bag), go ahead and let him take the trade. You will lose the item at this point.
player:addItem(4853); player:messageSpecial(ITEM_OBTAINED, 4853); -- Scroll of Drain
Give the player the "Scroll of Drain" as a reward, and display a message telling them they received it.
player:addFame(SANDORIA,SAN_FAME*30);
Add 30 fame as part of the reward (seems to be a standard value for completing a quest, so we will go with that).
player:completeQuest(SANDORIA,THE_RUMOR); end
Flag the quest as completed. and close the "if".
That brings us to the end of our changes. Our complete, updated script is as follows:
----------------------------------- -- Area: Bostaunieux Obliette -- NPC: Novalmauge -- Starts and Finishes Quest: The Rumor -- Involved in Quest: The Holy Crest, Trouble at the Sluice -- @zone 167 -- @pos 70 -24 21 ----------------------------------- package.loaded["scripts/zones/Bostaunieux_Oubliette/TextIDs"] = nil; ----------------------------------- require("scripts/globals/settings"); require("scripts/globals/titles"); require("scripts/globals/keyitems"); require("scripts/globals/shop"); require("scripts/globals/quests"); require("scripts/zones/Bostaunieux_Oubliette/TextIDs"); ----------------------------------- -- onTrade Action ----------------------------------- function onTrade(player,npc,trade) if(player:getVar("troubleAtTheSluiceVar") == 2) then if(trade:hasItemQty(959,1) and trade:getItemCount() == 1) then -- Trade Dahlia player:startEvent(0x0011); end end if(player:getQuestStatus(SANDORIA,THE_RUMOR) == QUEST_ACCEPTED) then count = trade:getItemCount(); BeastBlood = trade:hasItemQty(930,1) if(BeastBlood == true and count == 1) then player:startEvent(0x000c); end end end; ----------------------------------- -- onTrigger Action ----------------------------------- function onTrigger(player,npc) troubleAtTheSluice = player:getQuestStatus(SANDORIA,TROUBLE_AT_THE_SLUICE); TheHolyCrest = player:getVar("TheHolyCrest_Event"); tatsVar = player:getVar("troubleAtTheSluiceVar"); theRumor = player:getQuestStatus(SANDORIA,THE_RUMOR); -- The Holy Crest Quest if(TheHolyCrest == 1) then player:startEvent(0x0006); elseif(TheHolyCrest == 2) then player:startEvent(0x0007); -- Trouble at the Sluice Quest elseif(tatsVar == 1) then player:startEvent(0x000f); player:setVar("troubleAtTheSluiceVar",2); elseif(tatsVar == 2) then player:startEvent(0x0010); -- The rumor Quest elseif(theRumor == QUEST_AVAILABLE and player:getFameLevel(SANDORIA) >= 3 and player:getMainLvl() >= 10) then player:startEvent(0x000d); elseif(theRumor == QUEST_ACCEPTED) then player:startEvent(0x000b); elseif(theRumor == QUEST_COMPLETED) then player:startEvent(0x000e); -- Standard dialog after "The Rumor" else player:startEvent(0x000a); -- Standard dialog 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 == 0x0006) then player:setVar("TheHolyCrest_Event",2); elseif(csid == 0x0011) then player:tradeComplete(); player:addKeyItem(NEUTRALIZER); player:messageSpecial(KEYITEM_OBTAINED,NEUTRALIZER); player:setVar("troubleAtTheSluiceVar",0); elseif(csid == 0x000d and option == 1) then player:addQuest(SANDORIA,THE_RUMOR); elseif(csid == 0x000c) then if (player:getFreeSlotsCount() == 0) then player:messageSpecial(ITEM_CANNOT_BE_OBTAINED,4853); -- Scroll of Drain else player:tradeComplete(); player:addItem(4853); player:messageSpecial(ITEM_OBTAINED, 4853); -- Scroll of Drain player:addFame(SANDORIA,SAN_FAME*30); player:completeQuest(SANDORIA,THE_RUMOR); end end end;
Testing Your New Quest
Alright, the next thing you should do before sending off your quest is to test it on your own server. Save the modified script and replace scripts/zones/Bostaunieux_Oubliette/npcs/Novalmauge.lua with your new version. You can make a backup first, or use the svn revert feature to restore it to the current version later.
Make sure you can satisfy the requirements of the quest. Fire up your server and head to Bostaunieux Oubliette. Try and follow the quest as detailed on FFXIclopedia and make sure everything goes as planned. You should go out of your way to test things you aren't too sure about, as well.
I ran through this quest, and managed to complete it perfectly. It flagged properly in my journal, was marked as completed, and Novalmauge's default dialogue has now changed. Everything went according to plan!
Once you are satisfied with your quest script, start a new topic in the Scripting section on the dsp forums. Use the name of the quest as the topic, and make sure you attach the quest script! Someone with subversion access will test the script again and commit it if they do not find any problems. Check back with the topic until it has been committed in case you need to add anything else!