diff --git a/data/dfndata/colors/colors.dfn b/data/dfndata/colors/colors.dfn index 6fa82dfdb..d67fde057 100644 --- a/data/dfndata/colors/colors.dfn +++ b/data/dfndata/colors/colors.dfn @@ -1746,6 +1746,44 @@ 0x847c } +// reward satchel colors +[RANDOMCOLOR 300] +{ +0x1C +0x37 +0x71 +0x3A +0x62 +0x44 +0x59 +0x13 +0x21 +0x3 +0xD +0x3F +} + +// reward bag colors +[RANDOMCOLOR 301] +{ +0x385 +0x3E9 +0x4B0 +0x4E6 +0x514 +0x54A +0x578 +0x5AE +0x5DC +0x612 +0x640 +0x676 +0x6A5 +0x6DA +0x708 +0x774 +} + // seed colors [RANDOMCOLOR 10003] { diff --git a/data/dfndata/items/ItemMenu.bulk.dfn b/data/dfndata/items/ItemMenu.bulk.dfn index 8d014ae24..590efb073 100644 --- a/data/dfndata/items/ItemMenu.bulk.dfn +++ b/data/dfndata/items/ItemMenu.bulk.dfn @@ -14344,6 +14344,31 @@ ADDITEM=churchatnightmini ITEMMENU=4 } +[ITEMMENU 262] +{ +questrewards.dfn +0x0e76=Miners Satchel +ADDITEM=minerssatchel +0x0e75=Chefs Stachel +ADDITEM=chefssatchel +0x0e75=Lumberjacks Stachel +ADDITEM=lumberjackssatchel +0x0e75=Smiths Stachel +ADDITEM=smithssatchel +0x0e75=Tinkers Stachel +ADDITEM=tinkerssatchel +0x108a=an old ring +ADDITEM=oldring +0x1088=an old necklace +ADDITEM=oldnecklace +0x0e85=Jacobs Pickaxe +ADDITEM=jacobspickaxe +0x1086=Clasp of Concentration +ADDITEM=claspofconcentration +<=Previous Menu +ITEMMENU=4 +} + [ITEMMENU 264] { ethereal_statues.dfn @@ -14406,6 +14431,7 @@ ADDITEM=molderingursinestatuette <=Previous Menu ITEMMENU=9 } + [ITEMMENU 300] { power_scrolls.dfn @@ -14783,4 +14809,4 @@ ADDITEM=thetownshipdeed ADDITEM=thecastleoftheresistancedeed <=Previous Menu ITEMMENU=4 -} \ No newline at end of file +} diff --git a/data/dfndata/items/ItemMenu.dfn b/data/dfndata/items/ItemMenu.dfn index 7f3e109a9..9625a4566 100644 --- a/data/dfndata/items/ItemMenu.dfn +++ b/data/dfndata/items/ItemMenu.dfn @@ -512,6 +512,8 @@ ITEMMENU=122 ITEMMENU=250 <=Holidays ITEMMENU=251 +<=Quest Rewards +ITEMMENU=262 <=Ethereal Mounts ITEMMENU=264 <=Previous Menu diff --git a/data/dfndata/items/itemlists/itemlists.dfn b/data/dfndata/items/itemlists/itemlists.dfn index b9eba6b67..1c72df0fd 100644 --- a/data/dfndata/items/itemlists/itemlists.dfn +++ b/data/dfndata/items/itemlists/itemlists.dfn @@ -1237,6 +1237,21 @@ veritegranite valoritegranite } +//Quest Item Lists +[ranomdtinkerssatchel] +{ +itemlist=tinkerssatchel +} + +[ITEMLIST tinkerssatchel] +{ +0x105D,3//Springs +0x105B,3//Axle +0x1055,3//Hinge +0x1010//iron Key +0x0f9e//Scissors +} + //************************** //* LISTOBJECTS START HERE * //************************** diff --git a/data/dfndata/items/misc/questrewards.dfn b/data/dfndata/items/misc/questrewards.dfn new file mode 100644 index 000000000..8aedaa594 --- /dev/null +++ b/data/dfndata/items/misc/questrewards.dfn @@ -0,0 +1,102 @@ +[minerssatchel] +{ +get=0x0e76 +name=Craftsman's Satchel +colorlist=300 +packitem=pickaxe +packitem=pickaxe +sectionid=minerssatchel +} + +[oldring] +{ +get=0x108a +name=an old ring +sectionid=oldring +} + +[oldnecklace] +{ +get=0x1088 +name=an old necklace +sectionid=oldnecklace +} + +[chefssatchel] +{ +get=0x0e75 +name=Craftsman's Satchel +colorlist=300 +packitem=0x1039//sackofflour +packitem=0x097f//skillet +sectionid=chefssatchel +} + +[lumberjackssatchel] +{ +get=0x0e75 +name=Craftsman's Satchel +colorlist=300 +packitem=0x0eed,15 +packitem=hatchet +sectionid=lumberjackssatchel +} + +[smithssatchel] +{ +get=0x0e75 +name=Craftsman's Satchel +colorlist=300 +packitem=0x1bef,10//ironingot +packitem=0x13E3//smithhammer +sectionid=smithssatchel +} + +[tinkerssatchel] +{ +get=0x0e75 +name=Craftsman's Satchel +colorlist=300 +packitem=0x1EBC +packitem=ranomdtinkerssatchel +sectionid=tinkerssatchel +} + +[jacobspickaxe] +{ +get=base_item +name=jacob's pickaxe +id=0x0e85 +newbie +weight=1100 +value=34 17 +layer=2 +lodamage=1 +hidamage=15 +hp=35 45 +spd=35 +str=25 +restock=10 +decay=1 +good=57 +maxuses=20 +usesleft=20 +custominttag=skillBonusID 45 +custominttag=skillBonusVal 100 +script=2200// uses left tooltip +script=4050// mining.js +script=5065//jacobspickaxe.js +script=5043//skill_boosting_equipment.js +sectionid=jacobspickaxe +} + +[claspofconcentration] +{ +get=0x1086 +name=Clasp of Concentration +newbie +staminaregen=2 +manaregen=1 +elementresist=5 5 0 0 +sectionid=claspofconcentration +} \ No newline at end of file diff --git a/data/dfndata/items/npcmenu.bulk.dfn b/data/dfndata/items/npcmenu.bulk.dfn index 80348a184..bce5259b8 100644 --- a/data/dfndata/items/npcmenu.bulk.dfn +++ b/data/dfndata/items/npcmenu.bulk.dfn @@ -972,3 +972,34 @@ NPC=krampus_minion ITEMMENU=5024 } +[ITEMMENU 5027] +{ +Quest Givers +<=New Player Quest Givers +ITEMMENU=5028 +<=Previous Menu +ITEMMENU=5003 +} + +[ITEMMENU 5028] +{ +Quest Givers +0x2106=Mugg the Miner +NPC=mugg +0x2107=Clairesse the Servant +NPC=clairesse +0x2106=Asandos the Chef +NPC=asandos +0x2106=Hargrove the Lumberjack +NPC=hargrove +0x2106=Gervis the Blacksmith Trainer +NPC=gervis +0x2106=Nibbet the Tinker +NPC=nibbet +0x2106=Jacob Waltzt the Miner Instructor +NPC=jacob +0x2106=Sarsmea Smythe the Focus Instructor +NPC=sarsmea +<=Previous Menu +ITEMMENU=5024 +} \ No newline at end of file diff --git a/data/dfndata/items/npcmenu.dfn b/data/dfndata/items/npcmenu.dfn index 72f75528a..27eff93fe 100644 --- a/data/dfndata/items/npcmenu.dfn +++ b/data/dfndata/items/npcmenu.dfn @@ -54,7 +54,7 @@ ITEMMENU=5017 <=Undead ITEMMENU=5018 <=Holiday Creatures -ITEMMENU=5018 +ITEMMENU=5024 <=Previous Menu ITEMMENU=5000 } @@ -70,6 +70,8 @@ ITEMMENU=5020 ITEMMENU=5021 <=Male Vendors ITEMMENU=5022 +<=Quest Givers +ITEMMENU=5027 <=Previous Menu ITEMMENU=5000 } \ No newline at end of file diff --git a/data/dfndata/npc/questgivers.dfn b/data/dfndata/npc/questgivers.dfn new file mode 100644 index 000000000..8bb2b7a0d --- /dev/null +++ b/data/dfndata/npc/questgivers.dfn @@ -0,0 +1,340 @@ +//-==========================================================- +//-= =- +//-= Ultima Offline eXperiment 3 =- +//-= Script name: questgivers.dfn =- +//-= Date: Dec 24st 2024 =- +//-= =- +//-==========================================================- + +[mugg] +{ +GET=basehuman +NAME=Mugg the Miner +TITLE=Quest Giver +ID=0x0190 +PRIV=4 +SKIN=0x840A +EQUIPITEM=0x2047//afro +EQUIPITEM=pickaxe +EQUIPITEM=boots +COLOR=0x901 +EQUIPITEM=shortpants +COLOR=0x3B2 +EQUIPITEM=shirt +COLOR=0x22B +EQUIPITEM=skullcap +COLOR=0x177 +EQUIPITEM=halfapron +COLOR=0x5F1 +EQUIPITEM=0x1414//plategloves +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802//questconversation.js +CUSTOMINTTAG=QuestID 1//More ore Please +} + +[clairesse] +{ +GET=basehuman +NAME=Clairesse the Servant +TITLE=Quest Giver +ID=0x0191 +PRIV=4 +SKIN=0x840B +EQUIPITEM=0x203D//pony tail +COLOR=0x458 +EQUIPITEM=plaindress +COLOR=0x3C9 +EQUIPITEM=shoes +COLOR=0x740 +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 2//Stitch In Time +} + +[asandos] +{ +GET=basehuman +NAME=Asandos the Chef +TITLE=Quest Giver +ID=0x0190 +PRIV=4 +SKIN=0x83FF +EQUIPITEM=0x2044//mohawk +COLOR=0x1 +EQUIPITEM=boots +COLOR=0x901 +EQUIPITEM=shortpants +EQUIPITEM=shirt +EQUIPITEM=cap +EQUIPITEM=halfapron +COLOR=0x28 +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 3//Bakers Dozen +} + +[hargrove] +{ +GET=basehuman +NAME=Hargrove the Lumberjack +TITLE=Quest Giver +ID=0x0190 +PRIV=4 +SKIN=0x83FF +EQUIPITEM=0x203C//long hair +EQUIPITEM=boots +COLOR=0x901 +EQUIPITEM=shirt +COLOR=0x288 +EQUIPITEM=bandana +COLOR=0x20 +EQUIPITEM=battleaxe +EQUIPITEM=studdedleggings +EQUIPITEM=0x1414//plategloves +COLOR=0x21E +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 4//Chop Chop On The Double +} + +[gervis] +{ +GET=basehuman +NAME=Gervis the Blacksmith Trainer +TITLE=Quest Giver +ID=0x0190 +PRIV=4 +SKIN=0x83F5 +EQUIPITEM=0x203B//Short Hair +COLOR=0x5EC +EQUIPITEM=boots +COLOR=0x3B2 +EQUIPITEM=shortpants +COLOR=0x1BB +EQUIPITEM=shirt +COLOR=0x71F +EQUIPITEM=fullapron +COLOR=0x3B2 +EQUIPITEM=0x13E3//smith hammer +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 5//Battered Bucklers +} + +[nibbet] +{ +GET=basehuman +NAME=Nibbet the Tinker +TITLE=Quest Giver +ID=0x0190 +PRIV=4 +SKIN=0x840C +EQUIPITEM=0x2044//Mohawk +COLOR=0x1 +EQUIPITEM=boots +COLOR=0x591 +EQUIPITEM=shortpants +COLOR=0xF8 +EQUIPITEM=shirt +COLOR=0x2D +EQUIPITEM=fullapron +COLOR=0x288 +EQUIPITEM=0x1414//plategloves +COLOR=0x21E +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 6//A clockwork puzzle +} + +[jacob] +{ +GET=basehuman +NAME=Jacob Waltzt the Miner Instructor +TITLE=Quest Giver +ID=0x0190 +PRIV=4 +SKIN=0x83F5 +EQUIPITEM=0x203B//Short Hair +COLOR=0x5EC +EQUIPITEM=widebrimhat +COLOR=0x966 +EQUIPITEM=boots +EQUIPITEM=shortpants +COLOR=0x370 +EQUIPITEM=shirt +COLOR=0x966 +EQUIPITEM=halfapron +COLOR=0x1BB +EQUIPITEM=pickaxe +STR=100 100 +DEX=100 100 +INT=25 25 +BLACKSMITHING=650 880 +SWORDSMANSHIP=680 680 +TACTICS=680 680 +ARMSLORE=640 1000 +MAGERY=640 1000 +MINING=650 880 +TINKERING=640 1000 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 7//The Delucian’s Lost Mine +} + +[sarsmea] +{ +GET=basehuman +NAME=Sarsmea Smythe the Focus Instructor +TITLE=Quest Giver +ID=0x0191 +PRIV=4 +SKIN=0x83F5 +EQUIPITEM=0x203B//Short Hair +COLOR=0x5EC +EQUIPITEM=leatherleggings +EQUIPITEM=ThighBoots +EQUIPITEM=leatherarmor +EQUIPITEM=studdedgloves +EQUIPITEM=leatherninjabelt +EQUIPITEM=studdedgorget +EQUIPITEM=platemailjingasa +STR=100 100 +DEX=100 100 +INT=25 25 +ANATOMY=1200 1200 +PARRYING=1200 1200 +HEALING=1200 1200 +TACTICS=1200 1200 +SWORDSMANSHIP=1200 1200 +FOCUS=1200 1200 +SCRIPT=5802 +CUSTOMINTTAG=QuestID 8//The Inner Warrior +} + +[nail] +{//example quest npc +GET=basehuman +NAME=Nail +TITLE=the reciever +ID=0x0190 +PRIV=4 +SKIN=0x840A +EQUIPITEM=0x2047//afro +EQUIPITEM=pickaxe +EQUIPITEM=boots +COLOR=0x901 +EQUIPITEM=shortpants +COLOR=0x3B2 +EQUIPITEM=shirt +COLOR=0x22B +EQUIPITEM=skullcap +COLOR=0x177 +EQUIPITEM=halfapron +COLOR=0x5F1 +EQUIPITEM=0x1414//plategloves +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802//conversation gump +CUSTOMINTTAG=QuestID 1//collect fruit quest +CUSTOMINTTAG=DeliveryQuestID 10//waiting for package +} + +[nail2] +{//example quest npc 2 +GET=basehuman +NAME=Nail +TITLE=the reciever +ID=0x0190 +PRIV=4 +SKIN=0x840A +EQUIPITEM=0x2047//afro +EQUIPITEM=pickaxe +EQUIPITEM=boots +COLOR=0x901 +EQUIPITEM=shortpants +COLOR=0x3B2 +EQUIPITEM=shirt +COLOR=0x22B +EQUIPITEM=skullcap +COLOR=0x177 +EQUIPITEM=halfapron +COLOR=0x5F1 +EQUIPITEM=0x1414//plategloves +STR=100 100 +DEX=100 100 +INT=25 25 +SCRIPT=5802//conversation gump +CUSTOMINTTAG=QuestRootID 1204 +} + +[dl_halloween_questgiver] +{ +GET=basehuman +NAME=Mortis +TITLE=the Pumpkin Herald +ID=0x0190 +PRIV=4 +SKIN=0x83EA +SCRIPT=5802 // quest_conversation_gump.js (your system) +CUSTOMINTTAG=QuestRootID 14000 +} + +[dl_christmas_questgiver] +{ +GET=basehuman +NAME=Hearthwarden +TITLE=of Yuletide +ID=0x0190 +PRIV=4 +SKIN=0x83EA +SCRIPT=5802 +CUSTOMINTTAG=QuestRootID 15000 +} + +[dl_july4_questgiver] +{ +GET=basehuman +NAME=Captain Bray +TITLE=of the Founders’ Watch +ID=0x0190 +PRIV=4 +SKIN=0x83EA +SCRIPT=5802 +CUSTOMINTTAG=QuestRootID 16000 +} + +[dl_easter_questgiver] +{ +GET=basehuman +NAME=Meadow-Singer +TITLE=of Spring +ID=0x0190 +PRIV=4 +SKIN=0x83EA +SCRIPT=5802 +CUSTOMINTTAG=QuestRootID 17000 +} + +[dl_newyears_questgiver] +{ +GET=basehuman +NAME=Timekeeper +TITLE=of the Turning Year +ID=0x0190 +PRIV=4 +SKIN=0x83EA +SCRIPT=5802 +CUSTOMINTTAG=QuestRootID 18000 +} \ No newline at end of file diff --git a/data/dictionaries/dictionary.CSY b/data/dictionaries/dictionary.CSY index 4c66d7eec..01293a24f 100644 --- a/data/dictionaries/dictionary.CSY +++ b/data/dictionaries/dictionary.CSY @@ -5686,6 +5686,44 @@ 19341=Tento recept už znáte. 19342=Naučili jste se nový recept: %i 19343=Musíte se ho naučit ze svitku. +// [19600-19700] Quest System +19600=Zacilte položku, u které chcete přepnout stav položky úkolu na , aby se zrušila +19601=Pro přepnutí stavu úkolu musí být předmět ve vašem batohu. +19602=Tento úkol neexistuje. +19603=Abyste mohli tento úkol odevzdat, musíte mít všechny požadované položky. +19604=Momentálně nejste na tomto úkolu. +19605=Úkol byl zcela odstraněn z vašeho postupu. +19606=Nemáte batoh. +19607=Dokončili jste výcvik dovedností pro tento úkol! +19608=Stále vám chybí nějaké předměty pro tento úkol. +19609=Všechny úkoly byly úspěšně odevzdány. +19610=Nemáte požadovanou položku k doručení. +19611=Doručili jste požadovanou položku! +19612=Dokončili jste všechny úkoly +19613=Nemáte požadovanou položku k doručení. +19614=Doručili jste požadovanou položku! +19615=Nepodařilo se vytvořit doručovací předmět. +19616=Již se na vás vztahuje svitek pro zrychlený nárůst dovedností. +19617=Úkol jste přijali. +19618=Na tomto úkolu již pracujete. +19619=Tento úkol lze splnit pouze jednou a vy jste ho již splnili. +19620=Předmět jste úspěšně doručili! +19621=Nemáte požadovaný předmět k doručení. +19622=Toto není správný NPC pro doručení předmětu. +19623=Úkol jste splnili! Nezapomeňte si vyzvednout odměnu. +19624=Postup úkolu nebyl aktualizován. +19625=Úkol jste zatím nesplnili. +19626=Data úkolu se nepodařilo načíst. +19627=Zlato bylo vloženo do vaší banky. +19628=Banka je plná. Zlato bylo přidáno do vašeho batohu. +19629=Nelze dále snižovat. Aktuální počet je 0. +19630=Tento předmět nelze vybavit, protože se jedná o úkolový předmět. +19631=Úkolové předměty nelze odhodit na zem. +19632=Tento předmět není označen jako úkolový předmět. +19633=Úkolové předměty můžete odhodit do nejvyšší úrovně batohu, pouze pokud je pro svůj úkol stále potřebujete. +19634=Hromady úkolových předmětů nelze odložit. +19635=Úkolové předměty nelze tímto způsobem použít. +19636=Úkolový předmět // [ 30000 - 30010 ] Systém šampionů 30000=Šampion padl na %i! 30001=Způsobil jsi šampionovi největší poškození! diff --git a/data/dictionaries/dictionary.ENG b/data/dictionaries/dictionary.ENG index 7c6a7ef35..398ae6b41 100644 --- a/data/dictionaries/dictionary.ENG +++ b/data/dictionaries/dictionary.ENG @@ -5686,6 +5686,44 @@ 19341=You already know this recipe. 19342=You have learned a new recipe: %i 19343=You must learn that recipe from a scroll. +// [19600-19700] Quest System +19600=Target the item you wish to toggle Quest Item status on to cancel +19601=The item must be in your backpack to toggle its quest status. +19602=This quest does not exist. +19603=You need to have all required quest items to turn in this quest. +19604=You are not currently on this quest. +19605=The quest has been completely removed from your progress. +19606=You do not have a backpack. +19607=You have completed the skill training for this quest! +19608=You are still missing some items for this quest. +19609=All quest items have been turned in successfully. +19610=You don't have the required item to deliver. +19611=You have delivered the required item! +19612=You have completed all quests +19613=You do not have the required item to deliver. +19614=You have delivered the required item! +19615=Failed to create the delivery item. +19616=You are already under the effect of an accelerated skillgain scroll. +19617=You have accepted the Quest. +19618=You are already working on this quest. +19619=This quest can only be completed once, and you have already completed it. +19620=You have successfully delivered the item! +19621=You don't have the required item to deliver. +19622=This is not the correct NPC to deliver the item. +19623=You've completed the quest! Don't forget to collect your reward. +19624=No progress updated for the quest. +19625=You haven't completed the quest yet. +19626=Quest data could not be retrieved. +19627=Gold has been deposited into your bank. +19628=Bank is full. Gold added to your backpack. +19629=Cannot decrease further. Current count is 0. +19630=This item cannot be equipped as it is a quest item. +19631=Quest items cannot be dropped on the ground. +19632=This item is not marked as a quest item. +19633=You can only drop quest items into the top-most level of your backpack while you still need them for your quest. +19634=Stacks of quest items cannot be unstacked. +19635=Quest items cannot be used in this way. +19636=Quest Item // [ 30000 - 30010 ] Champion System 30000=The Champion has fallen to %i ! 30001=You dealt the most damage to the Champion! diff --git a/data/dictionaries/dictionary.FRE b/data/dictionaries/dictionary.FRE index 33f64c63b..63a2382a3 100644 --- a/data/dictionaries/dictionary.FRE +++ b/data/dictionaries/dictionary.FRE @@ -5807,8 +5807,8 @@ 19233=Vous êtes étourdi par l'attaque et votre attaquant disparaît ! 19234=Votre confusion est passée, vous pouvez maintenant armer une arme ! 19235=Vous n'êtes plus mortellement blessé. -19236=Les plaies saignantes ont guéri, vous ne saignez plus ! -// [19301-19340] Animal lié et mort +19236=Les plaies saignantes ont guéri, vous ne saignez plus ! +// [19301-19330] Animal lié et mort 19301=Vous ne pouvez pas échanger un animal mort. 19302=L'animal mort ne peut pas attaquer. 19303=Vous n'êtes pas assez compétent pour ressusciter cet animal. @@ -5850,6 +5850,44 @@ 19341=Vous connaissez déjà cette recette. 19342=Vous avez appris une nouvelle recette : %i 19343=Vous devez apprendre cette recette à partir d'un parchemin. +// [19600-19700] Système de quête +19600=Ciblez l'objet dont vous souhaitez changer le statut d'objet de quête sur pour annuler +19601=L'objet doit être dans votre sac à dos pour changer son statut de quête. +19602=Cette quête n'existe pas. +19603=Vous devez avoir tous les objets de quête requis pour rendre cette quête. +19604=Vous n'êtes pas actuellement sur cette quête. +19605=La quête a été complètement supprimée de votre progression. +19606=Vous n'avez pas de sac à dos. +19607=Vous avez terminé l'entraînement aux compétences pour cette quête ! +19608=Il vous manque encore des objets pour cette quête. +19609=Tous les objets de quête ont été rendus avec succès. +19610=Vous n'avez pas l'objet requis à livrer. +19611=Vous avez livré l'objet requis ! +19612=Vous avez terminé toutes les quêtes +19613=Vous n'avez pas l'objet requis à livrer. +19614=Vous avez livré l'objet requis ! +19615=Échec de la création de l'objet de livraison. +19616=Vous êtes déjà sous l'effet d'un parchemin de gain de compétence accéléré. +19617=Vous avez accepté la quête. +19618=Vous travaillez déjà sur cette quête. +19619=Cette quête ne peut être accomplie qu'une seule fois, et vous l'avez déjà terminée. +19620=Vous avez livré l'objet avec succès ! +19621=Vous n'avez pas l'objet requis à livrer. +19622=Ce PNJ n'est pas le bon pour livrer l'objet. +19623=Vous avez terminé la quête ! N'oubliez pas de récupérer votre récompense. +19624=Aucune progression n'a été mise à jour pour la quête. +19625=Vous n'avez pas encore terminé la quête. +19626=Les données de la quête n'ont pas pu être récupérées. +19627=De l'or a été déposé dans votre banque. +19628=La banque est pleine. De l'or a été ajouté à votre sac à dos. +19629=Ne peut plus diminuer. Le nombre actuel est de 0. +19630=Cet objet ne peut pas être équipé, car il s'agit d'un objet de quête. +19631=Les objets de quête ne peuvent pas être déposés au sol. +19632=Cet objet n'est pas marqué comme objet de quête. +19633=Vous ne pouvez déposer des objets de quête que dans le niveau supérieur de votre sac à dos, tant que vous en avez encore besoin pour votre quête. +19634=Les piles d'objets de quête ne peuvent pas être décompactées. +19635=Les objets de quête ne peuvent pas être utilisés de cette façon. +19636=Objet de quête // [30000 - 30010] Système du Champion 30000=Le Champion a été vaincu par %i ! 30001=Vous avez infligé le plus de dégâts au Champion ! diff --git a/data/dictionaries/dictionary.GER b/data/dictionaries/dictionary.GER index 8cec50d60..9014ffff0 100644 --- a/data/dictionaries/dictionary.GER +++ b/data/dictionaries/dictionary.GER @@ -5686,6 +5686,44 @@ 19341=Du kennst dieses Rezept bereits. 19342=Du hast ein neues Rezept gelernt: %i 19343=Du musst dieses Rezept von einer Schriftrolle lernen. +// [19600-19700] Quest-System +19600=Zielen Sie auf das Objekt, dessen Quest-Objektstatus Sie ändern möchten, und drücken Sie , um abzubrechen. +19601=Das Objekt muss sich in Ihrem Rucksack befinden, um seinen Quest-Status zu ändern. +19602=Diese Quest existiert nicht. +19603=Sie müssen alle erforderlichen Quest-Objekte haben, um diese Quest abgeben zu können. +19604=Sie befinden sich derzeit nicht in dieser Quest. +19605=Die Quest wurde vollständig aus Ihrem Fortschritt entfernt. +19606=Sie haben keinen Rucksack. +19607=Sie haben das Fertigkeitstraining für diese Quest abgeschlossen! +19608=Ihnen fehlen noch einige Objekte für diese Quest. +19609=Alle Quest-Objekte wurden erfolgreich abgegeben. +19610=Sie haben das erforderliche Objekt nicht zum Abliefern. +19611=Sie haben das erforderliche Objekt abgeliefert! +19612=Du hast alle Quests abgeschlossen +19613=Du hast den benötigten Gegenstand nicht zum Abliefern. +19614=Du hast den benötigten Gegenstand abgeliefert! +19615=Liefergegenstand konnte nicht erstellt werden. +19616=Du stehst bereits unter dem Einfluss einer Schriftrolle für beschleunigten Fertigkeitserwerb. +19617=Du hast die Quest angenommen. +19618=Du arbeitest bereits an dieser Quest. +19619=Diese Quest kann nur einmal abgeschlossen werden, und du hast sie bereits abgeschlossen. +19620=Du hast den Gegenstand erfolgreich geliefert! +19621=Du hast den benötigten Gegenstand nicht. +19622=Dies ist nicht der richtige NPC für die Lieferung. +19623=Du hast die Quest abgeschlossen! Vergiss nicht, deine Belohnung abzuholen. +19624=Für die Quest wurde kein Fortschritt angezeigt. +19625=Du hast die Quest noch nicht abgeschlossen. +19626=Questdaten konnten nicht abgerufen werden. +19627=Gold wurde auf deine Bank eingezahlt. +19628=Bank ist voll. Gold wurde deinem Rucksack hinzugefügt. +19629=Kann nicht weiter verringert werden. Aktueller Zählerstand: 0. +19630=Dieser Gegenstand kann nicht angelegt werden, da es sich um einen Questgegenstand handelt. +19631=Questgegenstände können nicht auf den Boden fallen gelassen werden. +19632=Dieser Gegenstand ist nicht als Questgegenstand gekennzeichnet. +19633=Du kannst Questgegenstände nur in die oberste Ebene deines Rucksacks fallen lassen, solange du sie noch für deine Quest benötigst. +19634=Stapel von Questgegenständen können nicht auseinandergenommen werden. +19635=Questgegenstände können nicht auf diese Weise verwendet werden. +19636=Questgegenstand // [30000 - 30010] Champion-System 30000=Der Champion wurde von %i besiegt! 30001=Du hast dem Champion den meisten Schaden zugefügt! diff --git a/data/dictionaries/dictionary.ITA b/data/dictionaries/dictionary.ITA index fded04dd5..15acb8adf 100644 --- a/data/dictionaries/dictionary.ITA +++ b/data/dictionaries/dictionary.ITA @@ -5686,6 +5686,43 @@ 19341=Conosci già questa ricetta. 19342=Hai imparato una nuova ricetta: %i 19343=Devi imparare quella ricetta da una pergamena. +// [19600-19700] Sistema di missioni +19600=Seleziona l'oggetto di cui vuoi modificare lo stato di Oggetto missione su per annullare +19601=L'oggetto deve essere nel tuo zaino per modificare il suo stato di missione. +19602=Questa missione non esiste. +19603=Devi avere tutti gli oggetti missione richiesti per consegnare questa missione. +19604=Al momento non sei in questa missione. +19605=La missione è stata completamente rimossa dai tuoi progressi. +19606=Non hai uno zaino. +19607=Hai completato l'addestramento delle abilità per questa missione! +19608=Ti mancano ancora alcuni oggetti per questa missione. +19609=Tutti gli oggetti missione sono stati consegnati con successo. +19610=Non hai l'oggetto richiesto da consegnare. +19611=Hai consegnato l'oggetto richiesto! +19612=Hai completato tutte le missioni +19613=Non hai l'oggetto richiesto da consegnare. +19614=Hai consegnato l'oggetto richiesto! +19615=Impossibile creare l'oggetto da consegnare. +19616=Sei già sotto l'effetto di una pergamena di acquisizione abilità accelerata. +19617=Hai accettato la missione. +19618=Stai già lavorando a questa missione. +19619=Questa missione può essere completata una sola volta e l'hai già completata. +19620=Hai consegnato l'oggetto con successo! +19621=Non hai l'oggetto richiesto da consegnare. +19622=Questo non è il PNG corretto per consegnare l'oggetto. +19623=Hai completato la missione! Non dimenticare di ritirare la tua ricompensa. +19624=Nessun progresso aggiornato per la missione. +19625=Non hai ancora completato la missione. +19626=Impossibile recuperare i dati della missione. 19627=L'oro è stato depositato nella tua banca. +19628=La banca è piena. Oro aggiunto al tuo zaino. +19629=Impossibile diminuire ulteriormente. Il conteggio attuale è 0. +19630=Questo oggetto non può essere equipaggiato in quanto è un oggetto di missione. +19631=Gli oggetti di missione non possono essere lasciati a terra. +19632=Questo oggetto non è contrassegnato come oggetto di missione. +19633=Puoi lasciare gli oggetti di missione solo nel livello più alto del tuo zaino, finché ti servono per la missione. +19634=Le pile di oggetti di missione non possono essere rimosse. +19635=Gli oggetti di missione non possono essere utilizzati in questo modo. +19636=Oggetto di missione // [ 30000 - 30010 ] Sistema del Campione 30000=Il Campione è stato sconfitto da %i! 30001=Hai inflitto il maggior danno al Campione! diff --git a/data/dictionaries/dictionary.POL b/data/dictionaries/dictionary.POL index 50af51c7c..74b21e9a8 100644 --- a/data/dictionaries/dictionary.POL +++ b/data/dictionaries/dictionary.POL @@ -5686,6 +5686,44 @@ 19341=Znasz już ten przepis. 19342=Nauczyłeś się nowego przepisu: %i 19343=Musisz nauczyć się tego przepisu ze zwoju. +// [19600-19700] System zadań +19600=Wybierz przedmiot, którego status przedmiotu zadania chcesz zmienić na , aby anulować +19601=Przedmiot musi znajdować się w plecaku, aby zmienić jego status zadania. +19602=To zadanie nie istnieje. +19603=Musisz mieć wszystkie wymagane przedmioty zadania, aby oddać to zadanie. +19604=Obecnie nie wykonujesz tego zadania. +19605=Zadanie zostało całkowicie usunięte z postępów. +19606=Nie masz plecaka. +19607=Ukończyłeś szkolenie umiejętności dla tego zadania! +19608=Brakuje Ci nadal niektórych przedmiotów dla tego zadania. +19609=Wszystkie przedmioty zadania zostały pomyślnie oddane. +19610=Nie masz wymaganego przedmiotu do dostarczenia. +19611=Dostarczyłeś wymagany przedmiot! +19612=Ukończyłeś wszystkie zadania +19613=Nie masz wymaganego przedmiotu do dostarczenia. +19614=Dostarczyłeś wymagany przedmiot! +19615=Nie udało się utworzyć przedmiotu dostawy. +19616=Jesteś już pod wpływem zwoju przyspieszonego zdobywania umiejętności. +19617=Zaakceptowałeś zadanie. +19618=Już pracujesz nad tym zadaniem. +19619=To zadanie można wykonać tylko raz, a Ty już je ukończyłeś. +19620=Pomyślnie dostarczyłeś przedmiot! +19621=Nie masz wymaganego przedmiotu do dostarczenia. +19622=To nie jest właściwy NPC do dostarczenia przedmiotu. +19623=Ukończyłeś zadanie! Nie zapomnij odebrać nagrody. +19624=Brak aktualizacji postępu zadania. +19625=Jeszcze nie ukończyłeś zadania. +19626=Nie można pobrać danych zadania. +19627=Złoto zostało zdeponowane w twoim banku. +19628=Bank jest pełny. Złoto zostało dodane do twojego plecaka. +19629=Nie można dalej zmniejszyć. Aktualna liczba wynosi 0. +19630=Ten przedmiot nie może zostać wyposażony, ponieważ jest przedmiotem zadania. +19631=Przedmiotów zadania nie można upuścić na ziemię. +19632=Ten przedmiot nie jest oznaczony jako przedmiot zadania. +19633=Możesz upuścić przedmioty zadania tylko na najwyższy poziom swojego plecaka, jeśli nadal potrzebujesz ich do zadania. +19634=Stosy przedmiotów zadania nie mogą zostać rozłożone. +19635=Przedmiotów zadania nie można używać w ten sposób. +19636=Przedmiot zadania // [30000 - 30010] System Mistrza 30000=Mistrz został pokonany przez %i! 30001=Zadałeś najwięcej obrażeń Mistrzowi! diff --git a/data/dictionaries/dictionary.PTG b/data/dictionaries/dictionary.PTG index b6987987f..8b9aabf8a 100644 --- a/data/dictionaries/dictionary.PTG +++ b/data/dictionaries/dictionary.PTG @@ -5686,6 +5686,44 @@ 19341=Já conhece esta receita. 19342=Aprendeu uma nova receita: %i 19343=Deve aprender esta receita num pergaminho. +// [19600-19700] Sistema de missões +19600=Escolha o item que pretende alternar o estado do Item de Missão em para cancelar +19601=O item deve estar na sua mochila para alternar o seu estado de missão. +19602=Esta missão não existe. +19603=Precisas de ter todos os itens de missão necessários para entregar esta missão. +19604=Não se encontra nesta missão neste momento. +19605=A missão foi completamente afastada do seu progresso. +19606=Não tem mochila. +19607=Concluíste o treino de habilidade para esta missão! +19608=Ainda faltam alguns itens para esta missão. +19609=Todos os itens da missão foram entregues com sucesso. +19610=Não tem o artigo necessário para entregar. +19611=Entregou o item necessário! +19612=Concluíste todas as missões +19613=Não tem o artigo necessário para entregar. +19614=Entregou o item necessário! +19615=Falha ao criar o artigo de entrega. +19616=Já está sob o efeito de um pergaminho de ganho de habilidade acelerado. +19617=Aceitou a missão. +19618=Já está a trabalhar nesta missão. +19619=Esta missão só pode ser concluída uma vez, e já a completaste. +19620=Entregou o artigo com sucesso! +19621=Não tem o artigo necessário para entregar. +19622=Este não é o NPC correto para entregar o item. +19623=Concluíste a missão! Não se esqueça de recolher a sua recompensa. +19624=Nenhum progresso atualizado para a missão. +19625=Ainda não completaste a missão. +19626=Os dados da missão não puderam ser recuperados. +19627=Ouro foi depositado no seu banco. +19628=O banco está cheio. Ouro adicionado à sua mochila. +19629=Não é possível diminuir mais. A contagem atual é 0. +19630=Este item não pode ser equipado, pois é um item de missão. +19631=Os itens de missão não podem ser atirados para o chão. +19632=Este item não está marcado como item de missão. +19633=Só pode colocar os itens de missão no nível mais alto da sua mochila enquanto ainda precisar deles para a sua missão. +19634=As pilhas de itens de missão não podem ser desempilhadas. +19635=Os itens de missão não podem ser utilizados desta forma. +19636=Item de Missão // [ 30000 - 30010 ] Sistema de Campeões 30000=O Campeão desceu para %i! 30001=Causaste o maior dano ao Campeão! diff --git a/data/dictionaries/dictionary.SPA b/data/dictionaries/dictionary.SPA index 34f5560ab..3d415c99b 100644 --- a/data/dictionaries/dictionary.SPA +++ b/data/dictionaries/dictionary.SPA @@ -5686,6 +5686,43 @@ 19341=Ya conoces esta receta. 19342=Has aprendido una nueva receta: %i 19343=Debes aprender esa receta de un pergamino. +// [19600-19700] Sistema de misiones +19600=Selecciona el objeto cuyo estado de objeto de misión deseas cambiar al para cancelar +19601=El objeto debe estar en tu mochila para cambiar su estado de misión. +19602=Esta misión no existe. +19603=Debes tener todos los objetos de misión necesarios para entregar esta misión. +19604=Actualmente no estás en esta misión. +19605=La misión ha sido completamente eliminada de tu progreso. +19606=No tienes una mochila. +19607=¡Has completado el entrenamiento de habilidades para esta misión! +19608=Aún te faltan algunos objetos para esta misión. +19609=Todos los objetos de la misión han sido entregados exitosamente. +19610=No tienes el objeto requerido para entregar. +19611=¡Has entregado el objeto solicitado! +19612=Has completado todas las misiones +19613=No tienes el objeto solicitado para entregar. +19614=¡Has entregado el objeto solicitado! +19615=Error al crear el objeto de entrega. +19616=Ya estás bajo el efecto de un pergamino de adquisición de habilidad acelerada. +19617=Has aceptado la misión. +19618=Ya estás trabajando en esta misión. +19619=Esta misión solo se puede completar una vez y ya la has completado. +19620=¡Has entregado el objeto con éxito! +19621=No tienes el objeto necesario para entregar. +19622=Este no es el PNJ correcto para entregar el objeto. +19623=¡Has completado la misión! No olvides recoger tu recompensa. +19624=No se ha actualizado el progreso de la misión. +19625=Aún no has completado la misión. +19626=No se pudieron recuperar los datos de la misión. 19627=Se ha depositado oro en tu banco. +19628=El banco está lleno. Oro añadido a tu mochila. +19629=No se puede reducir más. La cuenta actual es 0. +19630=Este objeto no se puede equipar, ya que es un objeto de misión. +19631=Los objetos de misión no se pueden dejar caer al suelo. +19632=Este objeto no está marcado como objeto de misión. +19633=Solo puedes dejar caer objetos de misión en el nivel superior de tu mochila mientras los necesites para la misión. +19634=Las pilas de objetos de misión no se pueden desapilar. +19635=Los objetos de misión no se pueden usar de esta manera. +19636=Objeto de misión // [30000 - 30010] Sistema de Campeones 30000=¡El Campeón ha caído a manos de %i! 30001=¡Infligiste el mayor daño al Campeón! diff --git a/data/dictionaries/dictionary.ZRO b/data/dictionaries/dictionary.ZRO index a49b7576a..eadedcad9 100644 --- a/data/dictionaries/dictionary.ZRO +++ b/data/dictionaries/dictionary.ZRO @@ -5682,6 +5682,48 @@ 19337=(invulnerable) 19338=(frozen) 19339=(guarded) +19340=That creature’s spirit lacks cohesion. Try again in a few minutes. +19341=You already know this recipe. +19342=You have learned a new recipe: %i +19343=You must learn that recipe from a scroll. +// [19600-19700] Quest System +19600=Target the item you wish to toggle Quest Item status on to cancel +19601=The item must be in your backpack to toggle its quest status. +19602=This quest does not exist. +19603=You need to have all required quest items to turn in this quest. +19604=You are not currently on this quest. +19605=The quest has been completely removed from your progress. +19606=You do not have a backpack. +19607=You have completed the skill training for this quest! +19608=You are still missing some items for this quest. +19609=All quest items have been turned in successfully. +19610=You don't have the required item to deliver. +19611=You have delivered the required item! +19612=You have completed all quests +19613=You do not have the required item to deliver. +19614=You have delivered the required item! +19615=Failed to create the delivery item. +19616=You are already under the effect of an accelerated skillgain scroll. +19617=You have accepted the Quest. +19618=You are already working on this quest. +19619=This quest can only be completed once, and you have already completed it. +19620=You have successfully delivered the item! +19621=You don't have the required item to deliver. +19622=This is not the correct NPC to deliver the item. +19623=You've completed the quest! Don't forget to collect your reward. +19624=No progress updated for the quest. +19625=You haven't completed the quest yet. +19626=Quest data could not be retrieved. +19627=Gold has been deposited into your bank. +19628=Bank is full. Gold added to your backpack. +19629=Cannot decrease further. Current count is 0. +19630=This item cannot be equipped as it is a quest item. +19631=Quest items cannot be dropped on the ground. +19632=This item is not marked as a quest item. +19633=You can only drop quest items into the top-most level of your backpack while you still need them for your quest. +19634=Stacks of quest items cannot be unstacked. +19635=Quest items cannot be used in this way. +19636=Quest Item 19340=That creature's spirit lacks cohesion. Try again in a few minutes. 19341=You already know this recipe. 19342=You have learned a new recipe: %i diff --git a/data/js/item/doors.js b/data/js/item/doors.js index ceb96bbb0..0df17344a 100644 --- a/data/js/item/doors.js +++ b/data/js/item/doors.js @@ -40,6 +40,14 @@ function onUseChecked( pUser, iUsed ) } } + // Optional quest/item gating (non-house doors) + if( iUsed.GetTag( "reqQuestID" ) || iUsed.GetTag( "reqItemSection" )) + { + if( !DoorGateAllowsOpen( pUser, iUsed )) + return false; // gate exists and failed -> block use + // gate passed -> continue to normal door open/close logic + } + if( iUsed.isDoorOpen ) { if( !CloseDoor( iUsed )) @@ -491,3 +499,79 @@ function FindKeyInPack( pUser, pPack, iUsed ) } return false; } + +function DoorGateAllowsOpen( pUser, iDoor ) +{ + var socket = pUser.socket; + // 1) Quest completion gate (uses archived/completed quest list) + var reqQuestID = parseInt( iDoor.GetTag( "reqQuestID" ), 10 ); + if( !isNaN( reqQuestID ) && reqQuestID > 0 ) + { + var archived = TriggerEvent( 5800, "ReadArchivedQuests", pUser );// from your quest system + if( !archived || archived.indexOf( reqQuestID ) == -1 ) + { + SendDoorGateMsg( socket, iDoor, "You must complete the required quest first." ); + return false; + } + } + + // 2) Item section gate (checks pack for item.sectionID match) + var reqSection = iDoor.GetTag( "reqItemSection" ); + if( reqSection ) + { + var needAmt = parseInt( iDoor.GetTag( "reqItemAmount" ), 10 ); + if( isNaN( needAmt ) || needAmt <= 0 ) needAmt = 1; + + if( !HasSectionInPack( pUser.pack, "" + reqSection, needAmt )) + { + SendDoorGateMsg( socket, iDoor, "You lack the required item to open this." ); + return false; + } + } + + // If no gates exist, return false here so normal key logic runs + // But since we use this only when tags exist, returning true means "allowed". + return ( ( !isNaN( reqQuestID ) && reqQuestID > 0 ) || reqSection ) ? true : false; +} + +function SendDoorGateMsg( socket, iDoor, fallbackMsg ) +{ + if( !socket ) + return; + + var msg = iDoor.GetTag( "reqGateMsg" ); + if( msg ) + socket.SysMessage( msg ); + else + socket.SysMessage( fallbackMsg ); +} + +function HasSectionInPack( cont, sectionID, needAmt ) +{ + if( !cont ) + return false; + + var want = ( "" + sectionID ).toString().toLowerCase().trim(); + var count = 0; + + for( var it = cont.FirstItem(); !cont.FinishedItems(); it = cont.NextItem() ) + { + if( it.isContType ) + { + if( HasSectionInPack( it, want, needAmt )) + return true; + } + else + { + var itSection = ( "" + it.sectionID ).toString().toLowerCase().trim(); + + if( itSection == want ) + { + count += ( it.amount > 0 ? it.amount : 1 ); + if( count >= needAmt ) + return true; + } + } + } + return false; +} diff --git a/data/js/item/jacobspickaxe.js b/data/js/item/jacobspickaxe.js new file mode 100644 index 000000000..9cfe4eb6d --- /dev/null +++ b/data/js/item/jacobspickaxe.js @@ -0,0 +1,22 @@ +function onCreateDFN( objMade, objType ) +{ + if( objType == 0 ) + { + // Start a timer to replenish charges every 5 minutes + objMade.StartTimer( 5000, 0, 5065 ); // 300000 ms = 5 minutes + } +} + +function onTimer( timerObj, timerID ) +{ + if( timerID == 0 ) + { + // Ensure the pickaxe regains a charge only if it has fewer than 20 uses left + if( timerObj.usesLeft < 20 ) + { + timerObj.usesLeft += 1; // Regain one charge + } + // Restart the timer to continue regenerating charges + timerObj.StartTimer( 5000, 0, 5065 ); + } +} diff --git a/data/js/jse_fileassociations.scp b/data/js/jse_fileassociations.scp index 5b993348f..88e48a942 100644 --- a/data/js/jse_fileassociations.scp +++ b/data/js/jse_fileassociations.scp @@ -326,6 +326,7 @@ 5062=item/shrines.js 5063=item/recipescroll.js 5064=item/runicatlas.js +5065=item/jacobspickaxe.js 5070=item/dawns_music_box.js 5300=item/etherealstatuettes.js @@ -369,6 +370,21 @@ 5608=item/holidays/addondeedgump.js 5609=item/holidays/redeedaddon.js +//------------------------------------------- +// Quest System [5800-5850] +//------------------------------------------- +5800=server/quest/quest_core.js +5801=server/quest/quest_lists.js +5802=server/quest/quest_conversation_gump.js +5803=server/quest/quest_menu.js +5805=server/quest/quest_event_onquesttoggle.js +5806=server/quest/quest_item.js +5810=server/quest/quest_event_ondeathblow.js +5811=server/quest/quest_event_onskillgain.js +5812=server/quest/quest_event_onenterregion.js +5813=server/quest/quest_login.js +5814=server/quest/quest_seasonal_event.js + //------------------------------------------- // Magic Related Scripts [6000-6500] //------------------------------------------- diff --git a/data/js/server/global.js b/data/js/server/global.js index 4f918f982..d04e69e8c 100644 --- a/data/js/server/global.js +++ b/data/js/server/global.js @@ -1,12 +1,16 @@ /// // @ts-check // Global Script + // Supported Events trigger for every character/item, use with care +const questSystemEnabled = GetServerSetting( "QuestsEnabled" ); + /** @type { ( sockPlayer: Socket, pPlayer: Character ) => boolean } */ function onLogin( socket, pChar ) { const coreShardEra = EraStringToNum( GetServerSetting( "CoreShardEra" )); const youngPlayerSystem = GetServerSetting( "YoungPlayerSystem" ); + const loginQuest = false; // Display Admin Welcome Gump for characters on admin account, until a choice has been made if( pChar.accountNum == 0 ) @@ -43,6 +47,18 @@ function onLogin( socket, pChar ) pChar.AddScriptTrigger( 2508 ); } + // Attach OnQuest Toggle + if(!pChar.HasScriptTrigger( 5805 ) && questSystemEnabled ) + { + pChar.AddScriptTrigger( 5805 ); + } + + if( loginQuest && questSystemEnabled ) + { + // Show the login quest gump + TriggerEvent( 5813, "LoginQuest", pChar ); + } + if( youngPlayerSystem && pChar.account.isYoung ) { // Attach "Young" player script, if the account is young and does not have script @@ -122,6 +138,19 @@ function onCreatePlayer( pChar ) } } +/** @type { ( mChar: Character ) => boolean } */ +function onQuestGump( pUser ) +{ + if( ValidateObject( pUser ) && !pUser.dead && questSystemEnabled ) + { + TriggerEvent( 5803, "QuestMenu", pUser ); + } + else + { + pUser.SysMessage( "Something is wrong, pUser is not valid or is dead." ); + } +} + // Generic global-script function to look up data in /shared/jsWorldData/ folder function CheckCustomGlobalData( fileName, dataToCheck ) { diff --git a/data/js/server/quest/quest_conversation_gump.js b/data/js/server/quest/quest_conversation_gump.js new file mode 100644 index 000000000..d908ce286 --- /dev/null +++ b/data/js/server/quest/quest_conversation_gump.js @@ -0,0 +1,1163 @@ +/// +// @ts-check + +/** @type { ( pUser: Character, npcTarget: Character, questID: number ) => void } */ +function QuestConversationGump( pUser, npcTarget, questID ) +{ + if( !ValidateObject( pUser ) || !ValidateObject( npcTarget )) + return; + + var socket = pUser.socket; + if( socket == null ) + return; + + var playerSerial = pUser.serial; // Use player serial to filter quests + + // Fetch quest details using the provided quest ID + var quest = TriggerEvent( 5801, "QuestList", questID ); + + if( !quest ) + { + socket.SysMessage( GetDictionaryEntry( 19602, socket.language ));//This quest does not exist. + return; + } + + // Fetch player's progress for the current quest and filter by player serial + var questProgressArray = TriggerEvent( 5800, "ReadQuestProgress", pUser ); + var currentQuestProgress = null; + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + if( questEntry.questID == questID && questEntry.serial == playerSerial) + { + currentQuestProgress = questEntry; + break; + } + } + + // Determine the description to display + var description = quest.description; // Default description + if( currentQuestProgress ) + { + if( currentQuestProgress.completed ) + { + description = quest.complete; + } + else + { + description = quest.uncomplete; + } + } + + // Handle chain quests: If this is a chain quest and there is a nextQuestID + // Chain preview: show what would unlock next (branching-aware) + if (currentQuestProgress && currentQuestProgress.completed) + { + var nextQuestID = ResolveNextQuestID_Any(pUser, quest); + if (nextQuestID > 0) + { + var nextQuest = TriggerEvent(5801, "QuestList", nextQuestID); + if (nextQuest) + { + description += "

Next Quest Available: " + + nextQuest.title + "
" + nextQuest.description; + } + } + } + + var questConvoMenu = new Gump(); + questConvoMenu.AddPage( 0 ); + questConvoMenu.NoClose(); + questConvoMenu.AddBackground( 30, 120, 296, 520, 1579 ); // Background + questConvoMenu.AddGump( 70, 130, 1577 ); // Decorative gump + + // Quest title + var title = "
" + quest.title + "
"; + questConvoMenu.AddHTMLGump( 75, 205, 200, 30, true, false, title ); // Title + questConvoMenu.AddHTMLGump( 50, 230, 264, 100, true, true, description ); // Description + + if( !currentQuestProgress || !currentQuestProgress.completed ) + { + var objectives = GetQuestObjectives( quest, currentQuestProgress ); + questConvoMenu.AddHTMLGump( 50, 350, 264, 100, true, true, objectives ); // Objectives + } + + var rewards = GetQuestRewards( quest ); + questConvoMenu.AddHTMLGump( 50, 470, 264, 100, true, true, rewards ); // Rewards + + if( !currentQuestProgress ) + { + questConvoMenu.AddButton( 60, 600, 0x2EE0, 0x2EE2, 1, 0, 1 ); // Start quest button + questConvoMenu.AddButton( 220, 600, 0x2EF2, 0x2EF4, 1, 0, 2 ); // refuse button + } + else if( currentQuestProgress.completed ) + { + questConvoMenu.AddButton( 60, 600, 0x2EE0, 0x2EE2, 1, 0, 3 ); // Turn in quest button + } + else + { + questConvoMenu.AddButton( 50, 600, 0x2EF5, 0x2EF7, 1, 0, 4 ); // resign quest button + questConvoMenu.AddButton( 220, 600, 0x2EEC, 0x2EEE, 1, 0, 0 ); // close button + } + + // Lock the questID this gump represents (so onGumpPress doesn't re-resolve) + pUser.SetTempTag( "QuestConversationQuestID", questID ); + + questConvoMenu.Send( socket ); + questConvoMenu.Free(); +} + +/** @type { ( myObj: Socket, pressed: number, gump: GumpData ) => void } */ +function onGumpPress( pSock, pButton, gumpData ) +{ + var pUser = pSock.currentChar; + + if( !ValidateObject( pUser )) + return; + + // If this gump was opened from login, we will NOT have an NPC. + var loginQuestID = parseInt( pUser.GetTempTag( "questConversationID" ), 10 ); + var hasLoginQuest = ( loginQuestID !== null && loginQuestID !== undefined && loginQuestID > 0 ); + + var questNpc = null; + var playerQuestID = null; + var quest = null; + + if( hasLoginQuest ) + { + playerQuestID = loginQuestID; + quest = TriggerEvent( 5801, "QuestList", playerQuestID ); + } + else + { + var npcSer = parseInt( pUser.GetTag( "questNpcSerial" ), 10 ); + questNpc = CalcCharFromSer( npcSer ); + + if( !ValidateObject( questNpc )) + { + return; + } + + // Use the questID the gump was opened for (prevents re-resolving chain on click) + playerQuestID = parseInt(pUser.GetTempTag("QuestConversationQuestID"), 10); + if (isNaN(playerQuestID) || playerQuestID <= 0) + { + // Fallback if temp tag missing (shouldn't happen) + var initialQuestID = parseInt(questNpc.GetTag("QuestID"), 10); + playerQuestID = ResolvePlayerQuestID(pUser, initialQuestID); + } + quest = TriggerEvent(5801, "QuestList", playerQuestID); + } + + switch( pButton ) + { + case 0: // Close gump + // Important: clear the login temp tag so later NPC gumps don't mis-detect + if( hasLoginQuest ) + { + pUser.SetTempTag( "questConversationID", null ); + } + pUser.SetTempTag( "QuestConversationQuestID", null ); + break; + case 1: // Accept quest + TriggerEvent( 5800, "StartQuest", pUser, playerQuestID ); + if( hasLoginQuest ) + { + pUser.SetTempTag( "questConversationID", null ); + } + pUser.SetTempTag( "QuestConversationQuestID", null ); + break; + case 2: // Refuse quest + if( quest && quest.refuse ) + { + if( ValidateObject( questNpc )) + { + questNpc.TextMessage( quest.refuse ); + } + else + { + pSock.SysMessage( quest.refuse ); // login flow has no NPC + } + } + if( hasLoginQuest ) + { + pUser.SetTempTag( "questConversationID", null ); + } + pUser.SetTempTag( "QuestConversationQuestID", null ); + break; + case 3: // Turn in quest + if( playerQuestID ) + { + var playerPack = pUser.pack; + if( playerPack.totalItemCount >= playerPack.maxItems ) + { + pSock.SysMessage( GetDictionaryEntry( 1819, pSock.language )); // Your backpack cannot hold any more items! + return; + } + + var turnInSuccess = ProcessQuestTurnIn( pUser, playerQuestID ); // Handle item turn-in + if( turnInSuccess ) + { + TriggerEvent( 5800, "CompleteQuest", pUser, playerQuestID ); // Complete the quest + pUser.SoundEffect(0x5B5, true); + } + else + { + pSock.SysMessage( GetDictionaryEntry( 19603, pSock.language ));// You need to have all required quest items to turn in this quest. + } + } + pUser.SetTempTag( "QuestConversationQuestID", null ); + break; + case 4: // Resign Quest + ResignQuest( pUser, playerQuestID ); + ManageQuestItems( pUser, playerQuestID, false ); + pUser.SoundEffect( 0x5B3, true ); + pUser.SetTempTag( "QuestConversationQuestID", null ); + break; + default:break + } +} + +/** @type { ( player: Character, questID: number ) => boolean } */ +function ResignQuest( player, questID ) +{ + if( !ValidateObject( player )) + return false; + + var socket = player.socket; + if( socket == null ) + return false; + + var questProgressArray = TriggerEvent( 5800, "ReadQuestProgress", player ); + var newQuestProgressArray = []; + var questFound = false; + + var quest = TriggerEvent( 5801, "QuestList", questID ); + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + + // Skip the quest that needs to be resigned, effectively removing it + if( questEntry.questID == questID && questEntry.serial == player.serial ) + { + questFound = true; + + // Handle skill training quest resignation + if( quest.type == "skillgain" ) + { + player.SetTag( "AcceleratedSkillGain", null ); // Remove the tag + player.RemoveScriptTrigger( 5811 ); // Remove the quest skill gain script trigger + socket.SysMessage( "You have stopped accelerated training for " + GetSkillName( quest.targetSkill ) + "." ); + } + + socket.SysMessage( "You have resigned from the quest: " + quest.title ); + } + else + { + newQuestProgressArray.push( questEntry ); + } + } + + if( !questFound ) + { + socket.SysMessage( GetDictionaryEntry( 19604, socket.language ));//You are not currently on this quest. + return false; + } + + // Write back the updated quest progress, excluding the resigned quest + TriggerEvent( 5800, "WriteQuestProgress", player, newQuestProgressArray ); + + socket.SysMessage( GetDictionaryEntry(19605, socket.language ));//The quest has been completely removed from your progress. + return true; +} + +/** @type { ( player: Character, questID: number, mark: boolean ) => void } */ +function ManageQuestItems( player, questID, mark ) +{ + if( !ValidateObject( player )) + return; + + var socket = player.socket; + if( socket == null ) + return; + + var pack = player.pack; + + if( !ValidateObject( pack )) + { + socket.SysMessage( GetDictionaryEntry( 19606, socket.language )); // You do not have a backpack. + return; + } + + // Try to fetch quest info, but DO NOT early-return on failure when unmarking + var quest = TriggerEvent( 5801, "QuestList", questID ) || {}; + + // Build a quick lookup for sectionIDs (for mark path and as a fallback on unmark) + var sectionLookup = {}; + if( quest.targetItems && quest.targetItems.length ) + { + for( var i = 0; i < quest.targetItems.length; i++ ) + { + // targetItems are data objects, not items; don't call GetTag on them + sectionLookup[String( quest.targetItems[i].sectionID )] = true; + } + } + + /** @type { ( item: Item ) => void } */ + function UnMarkItem( item ) + { + var saved = item.GetTag( "saveColor" ); + if( saved != null && !isNaN(parseInt( saved ))) + { + item.color = parseInt( saved ); + } + else + { + item.color = 0; + } + + item.isNewbie = false; + item.isDyeable = true; + + item.SetTag( "QuestItem", null ); + item.SetTag( "QuestSectionID", null ); + item.SetTag( "QuestID", null ); + item.SetTag( "saveColor", null ); + + item.RemoveScriptTrigger( 5806 ); + } + + // Optional: handle items inside nested containers + /** @type { ( container: Item, fn: ( item: Item ) => void ) => void } */ + function ForEachItemIn( container, fn ) + { + for (var containerItem = container.FirstItem(); !container.FinishedItems(); containerItem = container.NextItem()) + { + if( !ValidateObject( containerItem )) + continue; + + fn( containerItem ); + // If this item is itself a container, walk it too (API mirrors backpacks) + if( typeof containerItem.FirstItem === "function" && typeof containerItem.FinishedItems === "function" ) + { + ForEachItemIn( containerItem, fn ); + } + } + } + + ForEachItemIn( pack, function( currentItem ) + { + if( mark ) + { + // Collection targets + var matchesTarget = sectionLookup[String(currentItem.sectionID)] === true; + + // Delivery target + if( !matchesTarget && quest.type == "delivery" && quest.deliveryItem ) + { + matchesTarget = ( String( currentItem.sectionID ) == String( quest.deliveryItem.sectionID )); + } + + if( matchesTarget && !currentItem.GetTag( "QuestItem" )) + { + currentItem.SetTag( "saveColor", currentItem.color ); + currentItem.color = 0x04ea; // Orange hue + currentItem.isNewbie = true; + currentItem.isDyeable = false; + + currentItem.SetTag( "QuestItem", true ); + currentItem.SetTag( "QuestSectionID", currentItem.sectionID); + currentItem.SetTag( "QuestID", questID ); + + currentItem.AddScriptTrigger( 5806 ); + // socket.SysMessage("Marked item as a quest item."); + } + return; + } + + // UNMARK path (resign). Prefer exact QuestID match; fall back to section match if quest data exists. + if( currentItem.GetTag( "QuestItem" )) + { + var itemQuestID = currentItem.GetTag( "QuestID" ); + var itemSectionID = String( currentItem.GetTag( "QuestSectionID" ) || currentItem.sectionID ); + + var belongsToThisQuest = + ( itemQuestID != null && String( itemQuestID ) == String( questID )) || + ( sectionLookup[itemSectionID] === true ) || // fallback if QuestID wasn�t set earlier + ( itemQuestID == null && !quest.targetItems ); // last resort if we have no quest data at all + + if( belongsToThisQuest ) + { + UnMarkItem( currentItem ); + // socket.SysMessage("Unmarked quest item."); + } + } + + // Delivery items on resign: delete only if they belong to this quest + if( quest.type == "delivery" && quest.deliveryItem ) + { + if( String( currentItem.sectionID ) == String( quest.deliveryItem.sectionID )) + { + // avoid nuking unrelated items: require QuestID match or at least QuestItem tag + if( String( currentItem.GetTag( "QuestID" )) == String( questID ) || currentItem.GetTag( "QuestItem" )) + { + currentItem.Delete(); + socket.SysMessage( "Deleted delivery item: " + quest.deliveryItem.name ); + } + } + } + }); +} + +/** @type { ( quest: any, questProgress: any ) => string } */ +function GetQuestObjectives( quest, questProgress ) +{ + var objectives = ""; + + // Items to collect + if( quest.targetItems && quest.targetItems.length > 0 ) + { + objectives += "Items to Collect:
"; + for( var i = 0; i < quest.targetItems.length; i++ ) + { + var targetItem = quest.targetItems[i]; + var itemName = targetItem.name || "Unknown Item"; // Use `name` if available, fallback to "Unknown Item" + var collected = ( questProgress && questProgress.collectedItems[targetItem.sectionID] ) || 0; + + objectives += "- " + itemName + ": " + collected + "/" + targetItem.amount + "
"; + } + } + + // Creatures to kill + if( quest.targetKills && quest.targetKills.length > 0 ) + { + objectives += "Creatures to Kill:
"; + for( var j = 0; j < quest.targetKills.length; j++ ) + { + var targetKill = quest.targetKills[j]; + var npcName = targetKill.name || "Unknown Npc"; // Use `name` if available, fallback to "Unknown Npc" + var killed = ( questProgress && questProgress.harvestKills[targetKill.npcID] ) || 0; + + objectives += "- " + npcName + ": " + killed + "/" + targetKill.amount + "
"; + } + } + + // Time limit + if( quest.timeLimit ) + { + var minutes = Math.floor( quest.timeLimit / 60 ); // Convert seconds to minutes + var seconds = quest.timeLimit % 60; // Get remaining seconds + objectives += "Time Limit:
"; + objectives += "- " + minutes + " minute( s ) and " + seconds + " second( s )
"; + } + + // Delivery + if( quest.type == "delivery" ) + { + objectives += "Delivery Quest:
"; + + // Check for delivery item details + if( quest.deliveryItem ) + { + var itemName = quest.deliveryItem.name || "Unknown Item"; + objectives += "- Item: " + itemName + " ( " + quest.deliveryItem.amount + " )
"; + } + + // Check for targetDelivery details + if( quest.targetDelivery && quest.targetDelivery.name ) + { + objectives += "- NPC: " + quest.targetDelivery.name + "
"; + } + else if( quest.targetDelivery && quest.targetDelivery.sectionID ) + { + objectives += "- NPC: " + quest.targetDelivery.sectionID + "
"; // Fallback to sectionID if name is missing + } + + // Include location if provided + if( quest.targetDelivery.location ) + { + var loc = quest.targetDelivery.location; + objectives += "- Location: X=" + loc.x + ", Y=" + loc.y + ", Z=" + loc.z + "
"; + } + } + + // Skill objectives + if( quest.type == "skillgain" && quest.targetSkill != undefined && quest.maxSkillPoints != undefined ) + { + objectives += "Skill Training:
"; + var skillName = GetSkillName( quest.targetSkill ) || "Unknown Skill"; // Use a helper to get the skill name + var currentProgress = ( questProgress && questProgress.skillProgress ) || 0; // Default to 0 if not started + var maxProgress = quest.maxSkillPoints / 10; // Convert max skill points to in-game scale + var regionName = quest.regionName || "Unknown Region"; // Use `regionName` if available + + objectives += "- Train " + skillName + " in " + regionName + ": " + ( currentProgress / 10 ).toFixed( 1 ) + "/" + maxProgress.toFixed( 1 ) + "
"; + } + + // Daily Quest + if( quest.dailyQuest == 1 ) + { + objectives += "Daily Quest:
"; + var lastAccepted = questProgress && questProgress.lastAccepted ? questProgress.lastAccepted : Date.now(); + var resetTime = quest.resetDailyTime || 24; // Reset interval in hours + var currentTime = Date.now(); + + var timeSinceAccepted = ( currentTime - lastAccepted ) / ( 3600 * 1000 ); // Time in hours + var hoursLeft = Math.max(0, Math.ceil(resetTime - timeSinceAccepted )); + + if( hoursLeft > 0 ) + { + objectives += "- Hours until reset: " + hoursLeft + " hour(s)
"; + } + else + { + objectives += "- Quest is ready to reset!
"; + } + } + + if( objectives == "" ) + { + objectives = "No specific objectives."; + } + + return objectives; +} + +/** @type { ( skillID: number ) => string } */ +function GetSkillName( skillID ) +{ + var skillNames = [ + "alchemy", "anatomy", "animallore", "itemid", "armslore", "parrying", "begging", + "blacksmith", "bowcraft", "peacemaking", "camping", "carpentry", "cartography", + "cooking", "detectinghidden", "enticement", "evaluatingintelligence", "healing", + "fishing", "forensics", "herding", "hiding", "provocation", "inscription", + "lockpicking", "magery", "magicresistance", "tactics", "snooping", "musicianship", "poisoning", + "archery", "spiritSpeak", "stealing", "tailoring", "animaltaming", "tasteID", + "tinkering", "tracking", "veterinary", "swordsmanship", "macefighting", + "fencing", "wrestling", "lumberjacking", "mining", "meditation", "stealth", + "removetrap", "necromancy", "focus", "chivalry", "bushido", "ninjitsu", "spellweaving" + ]; + + return skillNames[skillID] || "unknown skill"; +} + +/** @type { ( quest: any ) => string } */ +function GetQuestRewards( quest ) +{ + // List rewards + var rewards = ""; + + if( quest.rewards && quest.rewards.length > 0 ) + { + rewards += "Rewards:
"; + for( var k = 0; k < quest.rewards.length; k++ ) + { + var reward = quest.rewards[k]; + var rewardName = reward.name || "Unknown Reward"; // Use `name` if available, fallback to "Unknown Reward" + rewards += "- " + reward.amount + " " + rewardName + "
"; + } + } + else + { + rewards = "No rewards specified."; + } + + return rewards; +} + +/** @type { ( player: Character, questID: number ) => boolean } */ +function ProcessQuestTurnIn( player, questID ) +{ + if( !ValidateObject( player )) + return false; + + var socket = player.socket; + if( socket == null ) + return false; + + // Fetch the quest details + var quest = TriggerEvent( 5801, "QuestList", questID ); + if( !quest ) + { + player.SysMessage( GetDictionaryEntry(19602, socket.language ));//This quest does not exist. + return false; + } + + var questProgressArray = TriggerEvent( 5800, "ReadQuestProgress", player ); + var questProgress = null; + + // Find the quest progress entry + for( var i = 0; i < questProgressArray.length; i++ ) + { + if( questProgressArray[i].questID == questID && questProgressArray[i].serial == player.serial ) + { + questProgress = questProgressArray[i]; + break; + } + } + + // Handle skill gain quests + if( quest.type == "skillgain" ) + { + if( questProgress.skillProgress >= quest.maxSkillPoints ) + { + socket.SysMessage(GetDictionaryEntry( 19607, socket.language ));//You have completed the skill training for this quest! + return true; + } + else + { + socket.SysMessage( "You still need to improve your skill. Current progress: " + ( questProgress.skillProgress / 10 ).toFixed( 1 ) + "/" + ( quest.maxSkillPoints / 10 ).toFixed( 1 ) ); + return false; + } + } + + // Check if the quest is a "kill" type and validate completion + if( quest.type == "kill" || quest.type == "timekills" || quest.type == "multi" ) + { + if( !questProgress || !questProgress.harvestKills ) + { + return false; + } + + // Validate kill objectives + for( var i = 0; i < quest.targetKills.length; i++ ) + { + var targetKill = quest.targetKills[i]; + if(( questProgress.harvestKills[targetKill.npcID] || 0 ) < targetKill.amount ) + { + socket.SysMessage( "You have not killed enough " + targetKill.npcID + "s." ); + return false; + } + } + + return true; + } + + // If the quest is not a kill quest, check for item turn-in + if( quest.type == "collect" || quest.type == "timecollect" || quest.type == "multi" ) + { + if( !quest.targetItems || quest.targetItems.length == 0 ) + { + return false; + } + + // Fetch quest items from the player's backpack + var questItems = FindQuestItems(player, questID); + var requiredItems = []; // Clone target items manually + + for( var i = 0; i < quest.targetItems.length; i++ ) + { + var targetItem = quest.targetItems[i]; + requiredItems.push({ sectionID: targetItem.sectionID, amount: targetItem.amount }); + } + + // Validate collected items against required objectives + for( var i = 0; i < questItems.length; i++ ) + { + var item = questItems[i]; + for( var j = 0; j < requiredItems.length; j++ ) + { + if( String( item.sectionID) == String( requiredItems[j].sectionID )) + { + var toDeduct = Math.min( item.amount, requiredItems[j].amount ); + requiredItems[j].amount -= toDeduct; // Deduct the required amount + item.amount -= toDeduct; // Deduct from the item's stack + + if( item.amount == 0 ) + { + item.Delete(); // Remove the item if the stack is empty + } + + if( requiredItems[j].amount == 0 ) + { + break; + } + } + } + } + + // Check if all required items are collected + for( var k = 0; k < requiredItems.length; k++ ) + { + if( requiredItems[k].amount > 0 ) + { + socket.SysMessage( GetDictionaryEntry( 19608, socket.language ));// You are still missing some items for this quest. + return false; + } + } + + socket.SysMessage( GetDictionaryEntry( 19609, socket.language ));// All quest items have been turned in successfully. + return true; + } + + // Handle delivery quests + if( quest.type == "delivery" ) + { + // Validate NPC + if( quest.targetDelivery.npcID != questNpc.serial ) + { + return false; + } + + // Find the delivery item in the backpack + var deliveryItems = FindQuestItems( player, questID ); + var foundItem = false; + + for( var i = 0; i < deliveryItems.length; i++ ) + { + var item = deliveryItems[i]; + if( String( item.sectionID ) == String( quest.deliveryItem.sectionID ) && item.amount >= quest.deliveryItem.amount ) + { + foundItem = true; + + // Remove the item from the player's backpack + if( item.amount > quest.deliveryItem.amount ) + { + item.amount -= quest.deliveryItem.amount; + } + else + { + item.Delete(); + } + break; + } + } + + if( !foundItem ) + { + socket.SysMessage( GetDictionaryEntry( 19610, socket.language ));// You don't have the required item to deliver. + return false; + } + + socket.SysMessage( GetDictionaryEntry( 19611, socket.language ));//You have delivered the required item! + return true; + } + + return false; +} + +/** @type { ( player: Character, questID: number ) => Item[] } */ +function FindQuestItems( player, questID ) +{ + if( !ValidateObject( player )) + return []; + + var socket = player.socket; + if( socket == null ) + return []; + + var questItems = []; + var pack = player.pack; // Get the player's backpack + + if( !ValidateObject( pack )) + { + socket.SysMessage( GetDictionaryEntry( 19606, socket.language )); + return questItems; // Return an empty array if no backpack is found + } + + var quest = TriggerEvent( 5801, "QuestList", questID ); + if( !quest || ( !quest.targetItems && !quest.deliveryItem )) + { + return questItems; + } + + // Build the list of required item IDs + var requiredsectionIDs = []; + if( quest.targetItems ) + { + for( var i = 0; i < quest.targetItems.length; i++ ) + { + requiredsectionIDs.push( String( quest.targetItems[i].sectionID )); + } + } + + // Add delivery item ID if it's a delivery quest + if( quest.type == "delivery" && quest.deliveryItem ) + { + requiredsectionIDs.push( String( quest.deliveryItem.sectionID )); + } + + var currentItem; + + // Iterate through all items in the player's backpack + for( currentItem = pack.FirstItem(); !pack.FinishedItems(); currentItem = pack.NextItem() ) + { + if( !ValidateObject( currentItem )) + { + continue; + } + + // Check if the item matches the quest and has the "QuestItem" tag + if( currentItem.GetTag( "QuestItem" ) && requiredsectionIDs.indexOf( String( currentItem.sectionID )) != -1 ) + { + questItems.push( currentItem ); + } + } + + return questItems; +} + +/** @type { ( currChar: Character, targChar: Character ) => boolean } */ +function onCharDoubleClick( pUser, questNpc ) +{ + if( !ValidateObject( pUser )) + return false; + + QuestNpcInterAction( pUser, questNpc ); + return true; +} + +/** @type { ( pUser: Character, questNpc: Character ) => boolean } */ +function QuestNpcInterAction( pUser, questNpc ) +{ + var gumpID = 5822 + 0xffff; + + if( !ValidateObject( pUser ) || !ValidateObject( questNpc ) ) + return false; + + var socket = pUser.socket; + if( socket == null ) + return false; + + pUser.SetTag( "questNpcSerial", questNpc.serial ); + + if( !questNpc.InRange( pUser, 2 ) ) + { + socket.SysMessage( "You are too far away." ); + return false; + } + + // ----------------------------- + // Delivery quest (leave your existing logic if you want) + // ----------------------------- + var deliveryQuestID = parseInt( questNpc.GetTag( "DeliveryQuestID" ), 10 ); + if( !isNaN( deliveryQuestID ) && deliveryQuestID > 0 ) + { + if( !IsQuestArchivedForPlayer( pUser, deliveryQuestID ) ) + { + if( TriggerEvent( 5800, "CheckQuest", pUser, deliveryQuestID, "check" ) ) + { + ProcessDeliveryQuest( pUser, questNpc, deliveryQuestID ); + return true; + } + } + } + + // ----------------------------- + // Normal quest chain (FIXED: branching-aware) + // ----------------------------- + var npcRootQuestID = GetNpcQuestRootID( questNpc ); + if( npcRootQuestID <= 0 ) + return false; + + var playerQuestID = ResolvePlayerQuestID( pUser, npcRootQuestID ); + if( !playerQuestID ) + { + questNpc.TurnToward( pUser ); + questNpc.TextMessage( GetDictionaryEntry( 19612, socket.language ) ); // You have completed all quests + return false; + } + + questNpc.TurnToward( pUser ); + socket.CloseGump( gumpID, 0 ); + QuestConversationGump( pUser, questNpc, playerQuestID ); + return true; +} + +/** @type { ( player: Character, questNpc: Character, deliveryQuestID: number ) => boolean } */ +function ProcessDeliveryQuest( player, questNpc, deliveryQuestID ) +{ + if( !ValidateObject( player )) + return false; + + var socket = player.socket; + if( socket == null ) + return false; + + // Fetch the quest details + var quest = TriggerEvent( 5801, "QuestList", deliveryQuestID ); + if( !quest || quest.type != "delivery" ) + { + questNpc.TextMessage( "This is not a delivery quest." ); + return false; + } + + // Ensure the NPC matches the quest's recipient + if( questNpc.sectionID != quest.targetDelivery.sectionID ) + { + questNpc.TextMessage( "I am not the intended recipient of this delivery." ); + return false; + } + + // Check if the player has the required item + var requiredItem = quest.deliveryItem; + var questItems = FindQuestItems( player, deliveryQuestID ); + var itemDelivered = false; + for( var i = 0; i < questItems.length; i++ ) + { + if( String( questItems[i].sectionID ) == String( requiredItem.sectionID )) + { + // Check if enough items are delivered + if( questItems[i].amount >= requiredItem.amount ) + { + itemDelivered = true; + } + break; + } + } + + if( !itemDelivered ) + { + socket.SysMessage( GetDictionaryEntry( 19613, socket.language ));//You do not have the required item to deliver. + return false; + } + + // Remove the item from the player's inventory + for( var j = 0; j < questItems.length; j++ ) + { + if( String( questItems[j].sectionID ) == String( requiredItem.sectionID )) + { + if( questItems[j].amount > requiredItem.amount ) + { + socket.SysMessage( "2"); + questItems[j].amount -= requiredItem.amount; + } + else + { + questItems[j].Delete(); + } + break; + } + } + + // Update the player's quest progress and notify + var questProgressArray = TriggerEvent( 5800, "ReadQuestProgress", player ); + for( var i = 0; i < questProgressArray.length; i++ ) + { + if( questProgressArray[i].questID == deliveryQuestID ) + { + socket.SysMessage( "3"); + questProgressArray[i].completed = true; + } + break; + } + + TriggerEvent( 5800, "WriteQuestProgress", player, questProgressArray ); + + // Notify the player and complete the quest + socket.SysMessage( GetDictionaryEntry( 19614, socket.language ));//You have delivered the required item! + TriggerEvent( 5800, "CompleteQuest", player, deliveryQuestID ); + return true; +} + +/** @type { ( questNpc: Character ) => number } */ +function GetNpcQuestRootID( questNpc ) +{ + // Preferred going forward + var rootQuestID = parseInt( questNpc.GetTag( "QuestRootID" ), 10 ); + if( !isNaN( rootQuestID ) && rootQuestID > 0 ) + return rootQuestID; + + // Backward compatible + var legacyQuestID = parseInt( questNpc.GetTag( "QuestID" ), 10 ); + if( !isNaN( legacyQuestID ) && legacyQuestID > 0 ) + return legacyQuestID; + + return 0; +} + +/** @type { ( player: Character, questID: number ) => boolean } */ +function IsQuestArchivedForPlayer( player, questID ) +{ + var archivedQuestIDs = TriggerEvent( 5800, "ReadArchivedQuests", player ) || []; + var questIDInt = parseInt( questID, 10 ); + + for( var index = 0; index < archivedQuestIDs.length; index++ ) + { + if( parseInt( archivedQuestIDs[index], 10 ) == questIDInt ) + return true; + } + return false; +} + +/** @type { ( player: Character, questID: number ) => boolean } */ +function IsQuestActiveForPlayer( player, questID ) +{ + var activeQuestEntries = TriggerEvent( 5800, "ReadQuestProgress", player ) || []; + var questIDInt = parseInt( questID, 10 ); + + for( var index = 0; index < activeQuestEntries.length; index++ ) + { + var entry = activeQuestEntries[index]; + if( entry && entry.serial == player.serial && parseInt( entry.questID, 10 ) == questIDInt ) + return true; + } + return false; +} + +/** @type { ( player: Character, questData: any ) => number } */ +function ResolveNextQuestID_Any( player, questData ) +{ + // Prefer the real branching resolver in script 5800 + var resolved = TriggerEvent( 5800, "ResolveNextQuestID", player, questData ); + resolved = parseInt( resolved, 10 ); + if( !isNaN( resolved ) && resolved > 0 ) + return resolved; + + // Fallback: old linear field + var fallback = parseInt( questData && questData.nextQuestID, 10 ); + if( !isNaN( fallback ) && fallback > 0 ) + return fallback; + + return 0; +} + +/** @type { ( player: Character, npcRootQuestID: number ) => ( number | null ) } */ +function ResolvePlayerQuestID( player, npcRootQuestID ) +{ + if( !ValidateObject( player ) ) + return null; + + var currentQuestID = parseInt( npcRootQuestID, 10 ); + if( isNaN( currentQuestID ) || currentQuestID <= 0 ) + return null; + + // Loop guard in case of bad data / circular chains + for( var hopCount = 0; hopCount < 50; hopCount++ ) + { + var questData = TriggerEvent( 5801, "QuestList", currentQuestID ); + if( !questData ) + return null; + + // If active, keep showing it + if( IsQuestActiveForPlayer( player, currentQuestID ) ) + return currentQuestID; + + // If not archived, this is the next offerable quest + if( !IsQuestArchivedForPlayer( player, currentQuestID ) ) + return currentQuestID; + + // Archived: advance using branching/linear + var nextQuestID = ResolveNextQuestID_Any( player, questData ); + if( nextQuestID <= 0 ) + return null; + + currentQuestID = nextQuestID; + } + + return null; +} + +/** @type { ( array: any[], value: number ) => boolean } */ +function isQuestArchived( array, value ) +{ + value = parseInt( value, 10 ); // Ensure value is a number + for( var i = 0; i < array.length; i++ ) + { + if( parseInt( array[i], 10 ) == value ) + { + return true; + } + } + return false; +} + +/** @type { ( value: any ) => boolean } */ +function isArray( value ) +{ + return Object.prototype.toString.call( value ) == "[object Array]"; +} + +/** @type { ( tSock: Socket, baseObj: BaseObject ) => boolean } */ +function onContextMenuRequest( socket, targObj ) +{ + // handle your own packet with context menu here + var pUser = socket.currentChar; + var offset = 12; + var numEntries = 2; + + // Prepare packet + var toSend = new Packet(); + var packetLen = ( 12 + ( numEntries * 8 )); + toSend.ReserveSize( packetLen ); + toSend.WriteByte( 0, 0xBF ); + toSend.WriteShort( 1, packetLen ); + toSend.WriteShort( 3, 0x14 ); // subCmd + toSend.WriteShort( 5, 0x0001 ); // 0x0001 for 2D client, 0x0002 for KR ( maybe this needs to be 0x0002? ) + toSend.WriteLong( 7, targObj.serial ); + toSend.WriteByte( 11, numEntries ); // Number of entries + + toSend.WriteShort( offset, 0x000A ); // Unique ID + toSend.WriteShort( offset += 2, 6156 ); // Quest Conversation + toSend.WriteShort( offset += 2, 0x0020 ); // Flag, color enabled + toSend.WriteShort( offset += 2, 0x03E0 ); // Hue of text + + offset += 2; // for each additional entry + + toSend.WriteShort( offset, 0x000C ); // Unique ID + toSend.WriteShort( offset += 2, 6155 ); // Cancel Quest + toSend.WriteShort( offset += 2, 0x0020 ); // Flag, color enabled + toSend.WriteShort( offset += 2, 0x03E0 ); // Hue of text + + //Send packet + socket.Send( toSend ); + toSend.Free(); + + return false; +} + +/** @type { ( tSock: Socket, baseObj: BaseObject, popupEntry: number ) => boolean } */ +function onContextMenuSelect( socket, questNpc, popupEntry ) +{ + var pUser = socket.currentChar; + + if( !ValidateObject( pUser ) || !ValidateObject( questNpc )) + { + return false; + } + + switch( popupEntry ) + { + case 0x000A: // Quest Conversation + { + if( !ValidateObject( pUser ) || !ValidateObject( questNpc )) + { + return false; + } + + if( !questNpc.InRange( pUser, 2 )) + { + pUser.SysMessage( "You are too far away." ); + return false; + } + + QuestNpcInterAction( pUser, questNpc ); + } + break; + case 0x000C: // Cancel Quest (Optional) + { + if( !ValidateObject( pUser ) || !ValidateObject( questNpc )) + { + return false; + } + + if( !questNpc.InRange( pUser, 2 )) + { + pUser.SysMessage( "You are too far away." ); + return false; + } + + var initialQuestID = parseInt( questNpc.GetTag( "QuestID" ), 10 ); + var playerQuestID = ResolvePlayerQuestID( pUser, initialQuestID ); + ResignQuest( pUser, playerQuestID ); + ManageQuestItems( pUser, playerQuestID, false ); + pUser.SoundEffect( 0x5B3, true ); + } + break; + default: + return true; + } + + return false; +} diff --git a/data/js/server/quest/quest_core.js b/data/js/server/quest/quest_core.js new file mode 100644 index 000000000..d355d2943 --- /dev/null +++ b/data/js/server/quest/quest_core.js @@ -0,0 +1,2226 @@ +/// +// @ts-check + +/** @type { ( player: Character, questID: number ) => void } */ +function StartQuest( player, questID ) +{ + if( !ValidateObject( player )) + return; + + var socket = player.socket; + if( socket == null ) + return; + + var questProgressArray = ReadQuestProgress( player ); + + if( !CheckQuest( player, questID )) + { + return; // Stop execution if the quest cannot proceed + } + + // Initialize objectives + var collectedItems = {}; + var harvestKills = {}; + + var quest = TriggerEvent( 5801, "QuestList", questID ); + + if( quest.targetItems ) + { + for( var i = 0; i < quest.targetItems.length; i++ ) + { + var targetItem = quest.targetItems[i]; + collectedItems[targetItem.sectionID] = 0; // Start with 0 collected + } + } + + if( quest.targetKills ) + { + for( var i = 0; i < quest.targetKills.length; i++ ) + { + var targetKill = quest.targetKills[i]; + harvestKills[targetKill.npcID] = 0; // Start with 0 kills + } + player.AddScriptTrigger( 5810 );// onKill quest trigger + } + + // Add the delivery item to the player's backpack if it's a delivery quest + if( quest.type == "delivery" && quest.deliveryItem ) + { + var package = CreateDFNItem( player.socket, player, quest.deliveryItem.sectionID, quest.deliveryItem.amount, "ITEM", true ) + if( ValidateObject( package )) + { + // Set the item's name to the delivery item name from the quest + package.name = quest.deliveryItem.name || "Unknown Package"; + + // Mark it as a quest item + package.SetTag( "saveColor", package.color ); + package.color = 0x04ea; // orange hue + package.isDyeable = false; + package.isNewbie = true; + package.SetTag( "QuestItem", true ); + package.SetTag( "delivery", true ); + package.SetTag( "QuestSectionID", package.sectionID ); + package.AddScriptTrigger( 5806 );// Quest Item script trigger + player.SysMessage( "You have received a " + quest.deliveryItem.name + " for delivery." ); + } + else + { + socket.SysMessage( GetDictionaryEntry( 19615, socket.language )); // Failed to create the delivery item. + } + } + + var initialSkillLevel = 0; + if( quest.type == "skillgain" ) + { + if( !player.GetTag( "AcceleratedSkillGain" )) + { + initialSkillLevel = player.baseskills[quest.targetSkill]; // Get current skill level + player.SetTag( "AcceleratedSkillGain", quest.targetSkill ); + player.AddScriptTrigger( 5811 ); // Quest skill gain script trigger + } + else + { + socket.SysMessage( GetDictionaryEntry( 19616, socket.language )); // You are already under the effect of an accelerated skillgain scroll. + } + } + + // Add new quest to progress + questProgressArray.push({ + serial: player.serial, + questID: questID, + questProgress: 0, // General progress + harvestKills: harvestKills, // Kill objectives + collectedItems: collectedItems, // Item objectives + skillProgress: initialSkillLevel, // Initialize skill progress + targetSkill: quest.targetSkill || -1, // Target skill for "skillgain" + targetRegion: quest.targetRegion || 0, // Target region for "skillgain" + maxSkillPoints: quest.maxSkillPoints || 50.0, // Max skill points for "skillgain" + startTime: quest.timeLimit ? Date.now() : 0, // Timed quests + timeLimit: quest.timeLimit ? quest.timeLimit * 1000 : 0, + lastAccepted: Date.now(), // Record the time the quest was accepted + completed: false, + questTurnIn: false, + nextQuestID: quest.nextQuestID || null + }); + + WriteQuestProgress( player, questProgressArray ); + + socket.SysMessage( GetDictionaryEntry( 19617, socket.language )); // You have accepted the Quest. + + // Start the timer if the quest is timed + if( quest.timeLimit ) + { + player.StartTimer( quest.timeLimit * 1000, questID, 5800 ); // Timer in milliseconds + var minutes = Math.floor( quest.timeLimit / 60 ); // Convert total seconds to minutes + var seconds = quest.timeLimit % 60; // Get remaining seconds + socket.SysMessage( "You have " + minutes + " minute( s ) and " + seconds + " second( s ) to complete this quest." ); + } +} + +/** @type { ( player: Character, questID: number, mode?: string ) => boolean } */ +function CheckQuest( player, questID, mode) +{ + mode = mode || "start"; // default + if( !ValidateObject( player )) + return false; + + var socket = player.socket; + if( socket == null ) + return false; + + var playerSerial = player.serial; // Use player serial to identify the quest owner + // Fetch quest details using the QuestList trigger + var quest = TriggerEvent( 5801, "QuestList", questID ); + if( !quest ) + { + socket.SysMessage( "Quest not found." ); + return false; // Indicate that the quest cannot proceed + } + + // Read the player's active and archived quest data + var questProgressArray = ReadQuestProgress( player ); + var archivedQuests = ReadArchivedQuests( player ); + + // Prerequisite quest gate + if( quest.requiresQuestID != null && quest.requiresQuestID != 0 ) + { + var reqID = parseInt( quest.requiresQuestID, 10 ); + if( reqID > 0 ) + { + // archivedQuests is an array of questIDs + if( archivedQuests.indexOf( reqID ) == -1 ) + { + var reqQuest = TriggerEvent( 5801, "QuestList", reqID ); + var reqName = ( reqQuest && reqQuest.title ) ? reqQuest.title : ( "Quest #" + reqID ); + + socket.SysMessage( "You must complete \"" + reqName + "\" before you can start this quest." ); + return false; + } + } + } + + // Check if already working on this quest + if( mode == "start" ) + { + // Check if the quest is already in progress + // Ensure no duplicate quests for the same player + for (var i = 0; i < questProgressArray.length; i++) + { + if( questProgressArray[i].questID == questID && questProgressArray[i].serial == playerSerial ) + { + socket.SysMessage( GetDictionaryEntry( 19618, socket.language )); // You are already working on this quest. + return false; + } + } + } + + // Check if the quest is marked as oneTimeQuest and already completed + if( quest.oneTimeQuest == 1 ) + { + for( var i = 0; i < archivedQuests.length; i++ ) + { + if( parseInt( archivedQuests[i], 10 ) == questID ) + { + socket.SysMessage( GetDictionaryEntry( 19619, socket.language )); // This quest can only be completed once, and you have already completed it. + return false; // Indicate that the quest cannot proceed + } + } + } + + // Check if the quest is a daily quest and ensure it has reset + if( quest.dailyQuest == 1 ) + { + for( var i = 0; i < archivedQuests.length; i++ ) + { + if( parseInt( archivedQuests[i], 10 ) == questID ) + { + var lastCompleted = archivedQuests[i].lastCompleted || 0; + var resetTime = quest.resetDailyTime || 24; // Default reset time is 24 hours + var currentTime = Date.now(); + + if(( currentTime - lastCompleted ) < resetTime * 3600 * 1000) + { + var hoursLeft = Math.ceil(( resetTime * 3600 * 1000 - ( currentTime - lastCompleted )) / ( 3600 * 1000 )); + socket.SysMessage( "This is a daily quest, and you must wait " + hoursLeft + " more hour(s) to attempt it again." ); + return false; // Indicate that the quest cannot proceed + } + } + } + } + socket.SoundEffect( 0x5B4, true ); + return true; +} + +/** @type { ( tObject: BaseObject, timerId: number ) => void } */ +function onTimer( timerObj, timerID ) +{ + var player = timerObj; // Assuming timerObj is the player object + var socket = player.socket; + var questProgressArray = ReadQuestProgress( player ); + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + if( questEntry.questID == timerID && !questEntry.completed ) + { + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + + TriggerEvent( 5802, "ManageQuestItems", player, questEntry.questID, false ); + + if( quest.type == "skillgain" ) + { + player.SetTag( "AcceleratedSkillGain", null ); // Remove the tag + player.RemoveScriptTrigger( 5811 ); // Remove the quest skill gain script trigger + socket.SysMessage( "You have stopped accelerated training for " + GetSkillName( quest.targetSkill ) + "." ); + } + + // Time has expired + socket.SysMessage( "You have failed the timed quest: " + quest.title ); + + // Log the failed quest + LogFailedQuest( player, questEntry ); + + questProgressArray.splice( i, 1 ); // Remove the failed quest + WriteQuestProgress( player, questProgressArray ); + return; + } + } +} + +/** @type { ( player: Character, questID: number, identifier: number | string, progressValue: number, type: string ) => any[] } */ +function UpdateQuestProgress( player, questID, identifier, progressValue, type ) +{ + if( !ValidateObject( player )) + return; + + var socket = player.socket; + if( socket == null ) + return; + + var questProgressArray = ReadQuestProgress( player ); + var questUpdated = false; + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + + // Ensure the quest belongs to the current player + if( questEntry.serial != player.serial || questEntry.questID != questID ) + { + continue; + } + + if( questEntry.questID == questID ) + { + var quest = TriggerEvent( 5801, "QuestList", questID ); // Fetch quest data + var allObjectivesCompleted = true; + + // Check item collection objectives + if( quest.type == "collect" || quest.type == "timecollect" || quest.type == "multi" ) + { + if( quest.targetItems ) + { + for( var j = 0; j < quest.targetItems.length; j++ ) + { + var target = quest.targetItems[j]; + + if( String( target.sectionID ) == String( identifier )) + { + questEntry.collectedItems[identifier] = Math.max( 0, questEntry.collectedItems[identifier] + progressValue ); + + // Cap the collected amount to the target amount + if( questEntry.collectedItems[identifier] > target.amount ) + { + questEntry.collectedItems[identifier] = target.amount; + } + } + + // Check if all items are collected + if( !questEntry.collectedItems || ( questEntry.collectedItems[target.sectionID] ) < target.amount ) + { + allObjectivesCompleted = false; + } + } + } + } + + // Check kill objectives + if( quest.type == "kill" || quest.type == "timekills" || quest.type == "multi" ) + { + if( quest.targetKills ) + { + for( var k = 0; k < quest.targetKills.length; k++ ) + { + var target = quest.targetKills[k]; + + if( String( target.npcID ) == String( identifier )) + { + questEntry.harvestKills[identifier] = ( questEntry.harvestKills[identifier] || 0 ) + progressValue; + + // Cap the kill count to the target amount + if( questEntry.harvestKills[identifier] > target.amount ) + { + questEntry.harvestKills[identifier] = target.amount; + } + } + + // Check if all kills are completed + if( !questEntry.harvestKills || ( questEntry.harvestKills[target.npcID] || 0 ) < target.amount ) + { + allObjectivesCompleted = false; + } + } + } + } + + if( quest.type == "delivery" ) + { + // Check if the player delivered the item to the correct NPC + if( identifier == quest.targetDelivery.npcID ) + { + // Fetch the delivery item from the player's backpack + var pack = player.pack; // Get the player's backpack + var hasItem = false; + + // Iterate through all items in the player's backpack + for( currentItem = pack.FirstItem(); !pack.FinishedItems(); currentItem = pack.NextItem()) + { + if( !ValidateObject( currentItem )) + { + continue; + } + + // Check if the item matches the delivery item and has the correct tag + /*var questSectionID = deliveryItem.GetTag( "QuestSectionID" ) || deliveryItem.sectionID; + if( currentItem.GetTag( "QuestItem" ) && String( currentItem.sectionID ) == String( quest.deliveryItem.sectionID || currentItem.sectionID == questSectionID ) && currentItem.amount >= quest.deliveryItem.amount ) + { + hasItem = true; + + // Remove the required quantity from the player's backpack + if( currentItem.amount > quest.deliveryItem.amount ) + { + currentItem.amount -= quest.deliveryItem.amount; // Reduce item stack + } + else + { + currentItem.Delete(); // Remove the item completely + } + break; + }*/ + var questSectionID = currentItem.GetTag("QuestSectionID") || currentItem.sectionID; + if ( currentItem.GetTag("QuestItem") && String(questSectionID) == String(quest.deliveryItem.sectionID) && currentItem.amount >= quest.deliveryItem.amount ) + { + hasItem = true; + + if (currentItem.amount > quest.deliveryItem.amount) + currentItem.amount -= quest.deliveryItem.amount; + else + currentItem.Delete(); + + break; + } + } + + if( hasItem ) + { + questEntry.completed = true; + socket.SysMessage( GetDictionaryEntry( 19620, socket.language )); // You have successfully delivered the item! + } + else + { + socket.SysMessage( GetDictionaryEntry( 19621, socket.language )); // You don't have the required item to deliver. + } + } + else + { + socket.SysMessage( GetDictionaryEntry( 19622, socket.language )); // This is not the correct NPC to deliver the item. + } + } + + // Check skill gain objectives + if( quest.type == "skillgain" && quest.targetSkill == identifier ) + { + // Update skill progress, initializing if undefined + questEntry.skillProgress = ( questEntry.skillProgress || 0 ) + progressValue; + + // Cap the skill progress to max skill points + if( questEntry.skillProgress >= quest.maxSkillPoints ) + { + // Complete the skill gain objective + player.SetTag( "AcceleratedSkillGain", null ); // Remove the tag + player.RemoveScriptTrigger( 5811 ); // Remove quest skill gain script trigger + questEntry.completed = true; // Mark the quest as completed + } + else + { + // Notify player of ongoing progress + socket.SysMessage( "Skill progress: " + ( questEntry.skillProgress / 10 ).toFixed(1) + "/" + ( quest.maxSkillPoints / 10 ).toFixed(1) ); + allObjectivesCompleted = false; + } + } + + // Mark quest as completed if all objectives are met + if( allObjectivesCompleted ) + { + if( quest.questTurnIn == 1 ) + { + questEntry.completed = true; + if( quest.type == "skillgain" && quest.oncomplete ) + { + socket.SysMessage( quest.oncomplete ); + } + else + { + socket.SysMessage( GetDictionaryEntry( 19623, socket.language )); + } + WriteQuestProgress( player, questProgressArray ); + } + else + { + questEntry.completed = true; + if (quest.type == "skillgain" && quest.oncomplete ) + { + socket.SysMessage( quest.oncomplete ); + } + else + { + socket.SysMessage( GetDictionaryEntry( 19623, socket.language )); // You've completed the quest! Don't forget to collect your reward. + } + WriteQuestProgress( player, questProgressArray ); + CompleteQuest( player, questID ); + } + return; + } + else + { + // If any objectives are now missing, make sure it's not marked as completed + questEntry.completed = false; + } + + questUpdated = true; + break; + } + } + + if( questUpdated ) + { + WriteQuestProgress( player, questProgressArray ); + } + else + { + socket.SysMessage( GetDictionaryEntry( 19624, socket.language )); // No progress updated for the quest. + } + + return questProgressArray; +} + +/** @type { ( player: Character, questID: number ) => void } */ +function CompleteQuest( player, questID ) +{ + if( !ValidateObject( player )) + return; + + var socket = player.socket; + if( socket == null ) + return; + + var questProgressArray = ReadQuestProgress( player ); + var newQuestProgressArray = []; + var questCompleted = false; + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + // Ensure the quest is tied to the current player and matches the questID + if( questEntry.serial != player.serial || questEntry.questID != questID ) + { + newQuestProgressArray.push( questEntry ); + continue; + } + + // Ensure the quest is completed + if( !questEntry.completed ) + { + socket.SysMessage( GetDictionaryEntry( 19625, socket.language ));//You haven't completed the quest yet. + return; + } + + var quest = TriggerEvent( 5801, "QuestList", questID ); + if( !quest ) + { + socket.SysMessage( GetDictionaryEntry( 19626, socket.language )); //Quest data could not be retrieved. + return; + } + + // Notify the player and play a visual effect + socket.SysMessage( "Congratulations! You have completed the quest: " + quest.title ); + DoStaticEffect( player.x, player.y, player.z, 0x376A, 0x40, 0x16, false ); + + // Handle rewards + if( quest.rewards ) + { + QuestRewards( player, quest, socket ); + } + + // Archive the completed quest + ArchiveCompletedQuest( player, questEntry ); + + ApplyQuestSetTags( player, quest ); + ApplyQuestSetTagDeltas( player, quest ); + + ApplyQuestSetTempTags( player, quest ); + ApplyQuestSetTempTagDeltas( player, quest ); + + ApplyQuestSetWorldState( player, quest ); + ApplyQuestWorldStateDeltas( player, quest ); + + // Cancel any active quest timer for this questID + player.KillJSTimer( questID, 5800 ); + + // Mark the quest as completed + questCompleted = true; + } + + // Save updated progress, excluding the completed quest + WriteQuestProgress( player, newQuestProgressArray ); + + // Handle chained quests + if( questCompleted ) + { + StartNextQuestInChain( player, questID, socket ); + } +} + +/** @type { ( player: Character, quest: any, socket: Socket ) => void } */ +function QuestRewards( player, quest, socket ) +{ + if( !ValidateObject( player )) + return; + + var bankBox = player.FindItemLayer( 29 ); + + quest.rewards.forEach( function( reward ) + { + switch( reward.type ) + { + case "gold": + GoldReward( player, reward, bankBox, socket ); + break; + case "item": + { + var color = 0; + if( reward.color != null ) + { + color = parseInt( reward.color, 10 ); + } + else if( reward.hue != null ) + { + color = parseInt( reward.hue, 10 ); + } + + if( isNaN( color )) + color = 0; + + CreateDFNItem(player.socket, player, reward.sectionID, reward.amount, "ITEM", true, color ); + socket.SysMessage("You receive a reward: " + reward.amount + " of item " + reward.name + "!"); + break; + } + case "karma": + player.karma += reward.amount; + socket.SysMessage( "You gained " + reward.amount + " karma!" ); + break; + case "fame": + player.fame += reward.amount; + socket.SysMessage( "You gained " + reward.amount + " fame!" ); + break; + case "skill": + SkillReward( player, reward, socket ); + break; + case "skillpoints": + SkillPointsPoolReward( player, reward, socket ); + break; + case "virtue": + { + // Prefer numeric index 0..7 in reward.virtueIndex + var vIdx = parseInt( reward.virtueIndex, 10); + if( isNaN( vIdx )) + vIdx = 0; + + var amt = parseInt( reward.amount, 10 ) || 0; + if( amt <= 0 ) + { + socket.SysMessage( "Virtue reward amount invalid." ); + break; + } + + var result = TriggerEvent(8003, "Virtue_Award", player, vIdx, amt); + + if( result && result.success ) + { + if( result.gainedPath ) + socket.SysMessage("You have gained a path in virtue!"); + else + socket.SysMessage("You have gained in virtue."); + } + break; + } + default: + socket.SysMessage( "Unknown reward type: " + reward.type ); + break; + } + }); +} + +/** @type { ( player: Character, quest: any ) => void } */ +function ApplyQuestSetTags( player, quest ) +{ + if( !quest || !quest.setTags ) + return; + + for( var key in quest.setTags ) + { + if( !quest.setTags.hasOwnProperty( key )) + continue; + + var val = quest.setTags[key]; + + // null/undefined => remove tag + if( val == null ) + player.SetTag( key, null ); + else + player.SetTag( key, val ); + } +} + +/** @type { ( player: Character, quest: any ) => void } */ +function ApplyQuestSetTempTags( player, quest ) +{ + if( !quest || !quest.setTempTags ) + return; + + for( var key in quest.setTempTags ) + { + if( !quest.setTempTags.hasOwnProperty( key )) + continue; + + var val = quest.setTempTags[key]; + + // null/undefined => remove + if( val == null ) + player.SetTempTag( key, null ); + else + player.SetTempTag( key, val ); + } +} + +/** @type { ( player: Character, quest: any ) => void } */ +function ApplyQuestSetWorldState( player, quest ) +{ + if( !quest || !quest.setWorldState ) + return; + + for( var worldStateKey in quest.setWorldState ) + { + if( !quest.setWorldState.hasOwnProperty( worldStateKey ) ) + continue; + + var worldStateValue = quest.setWorldState[worldStateKey]; + + // null/undefined => remove the world-state key + if( worldStateValue == null ) + { + SetWorldStateInt( player, worldStateKey, null ); + } + else + { + SetWorldStateInt( player, worldStateKey, ToIntOrZero( worldStateValue ) ); + } + } +} + +/** @type { ( player: Character, quest: any ) => void } */ +function ApplyQuestWorldStateDeltas( player, quest ) +{ + if( !quest || !quest.worldStateDeltas ) + return; + + for( var worldStateKey in quest.worldStateDeltas ) + { + if( !quest.worldStateDeltas.hasOwnProperty( worldStateKey ) ) + continue; + + var deltaAmount = ToIntOrZero( quest.worldStateDeltas[worldStateKey] ); + var currentWorldStateValue = GetWorldStateInt( player, worldStateKey, 0 ); + var nextWorldStateValue = currentWorldStateValue + deltaAmount; + + // Optional clamp rules: + // - quest.worldStateDeltaRules[worldStateKey] overrides + // - else quest.worldStateDeltaRulesDefault + var clampRule = ResolveDeltaRule( quest, worldStateKey, "worldStateDeltaRules", "worldStateDeltaRulesDefault" ); + + if( clampRule ) + { + var hasMin = clampRule.hasOwnProperty( "min" ); + var hasMax = clampRule.hasOwnProperty( "max" ); + + var minAllowedValue = ( hasMin ? ToIntOrZero( clampRule.min ) : null ); + var maxAllowedValue = ( hasMax ? ToIntOrZero( clampRule.max ) : null ); + + if( hasMin || hasMax ) + { + nextWorldStateValue = ClampInt( + nextWorldStateValue, + hasMin ? minAllowedValue : null, + hasMax ? maxAllowedValue : null + ); + } + } + + SetWorldStateInt( player, worldStateKey, nextWorldStateValue ); + } +} + +/** @type { ( player: Character, quest: any ) => void } */ +function ApplyQuestSetTagDeltas( player, quest ) +{ + if( !quest || !quest.setTagDeltas ) + return; + + for( var key in quest.setTagDeltas ) + { + if( !quest.setTagDeltas.hasOwnProperty( key )) + continue; + + var deltaVal = quest.setTagDeltas[key]; + var delta = ToIntOrZero( deltaVal ); + + var cur = GetIntTagOrZero( player, key ); + var next = cur + delta; + + // Optional clamp + var rule = ResolveDeltaRule( quest, key, "deltaRules", "deltaRulesDefault" ); + if( rule ) + { + var minVal = ( rule.hasOwnProperty( "min" ) ? ToIntOrZero( rule.min ) : null ); + var maxVal = ( rule.hasOwnProperty( "max" ) ? ToIntOrZero( rule.max ) : null ); + + // If they specify only min or only max, we still clamp correctly + if( rule.hasOwnProperty( "min" ) || rule.hasOwnProperty( "max" )) + next = ClampInt( next, rule.hasOwnProperty( "min" ) ? minVal : null, rule.hasOwnProperty( "max" ) ? maxVal : null ); + } + + player.SetTag( key, next ); + } +} + +/** @type { ( player: Character, quest: any ) => void } */ +function ApplyQuestSetTempTagDeltas( player, quest ) +{ + if( !quest || !quest.setTempTagDeltas ) + return; + + for( var key in quest.setTempTagDeltas ) + { + if( !quest.setTempTagDeltas.hasOwnProperty( key )) + continue; + + var deltaVal = quest.setTempTagDeltas[key]; + var delta = ToIntOrZero( deltaVal ); + + var cur = GetIntTempTagOrZero( player, key ); + var next = cur + delta; + + // Optional clamp + var rule = ResolveDeltaRule( quest, key, "tempDeltaRules", "tempDeltaRulesDefault" ); + if( rule ) + { + var minVal = ( rule.hasOwnProperty( "min" ) ? ToIntOrZero( rule.min ) : null ); + var maxVal = ( rule.hasOwnProperty( "max" ) ? ToIntOrZero( rule.max ) : null ); + + if( rule.hasOwnProperty( "min" ) || rule.hasOwnProperty( "max" )) + next = ClampInt( next, rule.hasOwnProperty( "min" ) ? minVal : null, rule.hasOwnProperty( "max" ) ? maxVal : null ); + } + + player.SetTempTag( key, next ); + } +} + +/** @type { ( v: any ) => number } */ +function ToIntOrZero( v ) +{ + if( v == null || v === "" ) + return 0; + + var n = parseInt( v, 10 ); + if( isNaN( n )) + return 0; + + return n; +} + +/** @type { ( v: number, minVal: number | null, maxVal: number | null ) => number } */ +function ClampInt( v, minVal, maxVal ) +{ + if( minVal != null && v < minVal ) v = minVal; + if( maxVal != null && v > maxVal ) v = maxVal; + return v; +} + +/** @type { ( obj: BaseObject, tagName: string ) => number } */ +function GetIntTagOrZero( obj, tagName ) +{ + return ToIntOrZero( obj.GetTag( tagName )); +} + +/** @type { ( obj: BaseObject, tagName: string ) => number } */ +function GetIntTempTagOrZero( obj, tagName ) +{ + if( !obj.GetTempTag ) + return 0; + return ToIntOrZero( obj.GetTempTag( tagName )); +} + +// Resolve the clamp rule for a given key: +// - if quest.deltaRules has a rule for this key, use it +// - else if quest.deltaRulesDefault exists, use that +// - else return null (no clamping) +/** @type { ( quest: any, key: string, rulesObjName: string, defaultRuleName: string ) => any } */ +function ResolveDeltaRule( quest, key, rulesObjName, defaultRuleName ) +{ + if( !quest ) + return null; + + var rules = quest[rulesObjName]; + if( rules && rules.hasOwnProperty( key ) && rules[key] ) + return rules[key]; + + var defRule = quest[defaultRuleName]; + if( defRule ) + return defRule; + + return null; +} + +/** @type { ( player: Character, reward: any, bankBox: Item, socket: Socket ) => void } */ +function GoldReward( player, reward, bankBox, socket ) +{ + if( reward.bankgold == 1 ) + { + if( ValidateObject( bankBox ) && bankBox.totalItemCount < bankBox.maxItems ) + { + var gold = CreateDFNItem( player.socket, player, "0x0eed", reward.amount, "ITEM", false ); + gold.container = bankBox + socket.SysMessage( GetDictionaryEntry( 19627, socket.language )); // Gold has been deposited into your bank. + } + else + { + socket.SysMessage( GetDictionaryEntry( 19628, socket.language )); // Bank is full. Gold added to your backpack. + CreateDFNItem( player.socket, player, "0x0eed", reward.amount, "ITEM", true ); + } + } + else + { + CreateDFNItem( player.socket, player, "0x0eed", reward.amount, "ITEM", true ); + } + socket.SysMessage( "You receive a reward: " + reward.amount + " gold!" ); +} + +/** @type { ( player: Character, questID: number, socket: Socket ) => void } */ +function StartNextQuestInChain( player, questID, socket ) +{ + if( !ValidateObject( player )) + return; + + var completedQuest = TriggerEvent( 5801, "QuestList", questID ); + if( !completedQuest ) + return; + + // Dynamic (branching) resolution + var nextQuestID = ResolveNextQuestID( player, completedQuest ); + if( nextQuestID <= 0 ) + return; + + // Safety: do not auto-start if already active or archived + if( HasActiveQuestID( player, nextQuestID )) + return; + + if( HasArchivedQuestID( player, nextQuestID )) + return; + + var nextQuest = TriggerEvent( 5801, "QuestList", nextQuestID ); + if( nextQuest ) + { + if( socket ) + socket.SysMessage( "A new quest has been unlocked: " + nextQuest.title ); + + StartQuest( player, nextQuestID ); + } +} + +/** @type { ( player: Character, questID: number ) => boolean } */ +function HasArchivedQuestID( player, questID ) +{ + var archivedQuestIDs = ReadArchivedQuests( player ) || []; + var questIDInt = parseInt( questID, 10 ); + + for( var i = 0; i < archivedQuestIDs.length; i++ ) + { + if( parseInt( archivedQuestIDs[i], 10 ) == questIDInt ) + return true; + } + return false; +} + +/** @type { ( player: Character, questID: number ) => boolean } */ +function HasActiveQuestID( player, questID ) +{ + var activeQuestEntries = ReadQuestProgress( player ) || []; + var questIDInt = parseInt( questID, 10 ); + + for( var i = 0; i < activeQuestEntries.length; i++ ) + { + var entry = activeQuestEntries[i]; + if( entry.serial == player.serial && parseInt( entry.questID, 10 ) == questIDInt ) + return true; + } + return false; +} + +/** @type { ( player: Character, condition: any ) => boolean } */ +function QuestCondPasses( player, condition ) +{ + if( !condition ) + return true; + + // tagEquals: { tag:"X", value: 1 } + if( condition.tagEquals ) + { + var tagName = condition.tagEquals.tag; + var expectedValue = condition.tagEquals.value; + + var currentValue = player.GetTag( tagName ); // UOX3 returns 0 if missing + return ( String( currentValue ) == String( expectedValue )); + } + + // tagMin: { tag:"X", value: 10 } + if( condition.tagMin ) + { + var minTagName = condition.tagMin.tag; + + var minRequiredValue = parseInt( condition.tagMin.value, 10 ); + if( isNaN( minRequiredValue )) minRequiredValue = 0; + + var currentTagValue = parseInt( player.GetTag( minTagName ), 10 ); + if( isNaN( currentTagValue )) currentTagValue = 0; + + return ( currentTagValue >= minRequiredValue ); + } + + // completedQuest: 123 + if( condition.completedQuest != null ) + return HasArchivedQuestID( player, condition.completedQuest ); + + // notCompletedQuest: 123 + if( condition.notCompletedQuest != null ) + return !HasArchivedQuestID( player, condition.notCompletedQuest ); + + // hasQuest: 123 + if( condition.hasQuest != null ) + return HasActiveQuestID( player, condition.hasQuest ); + + // notHasQuest: 123 + if( condition.notHasQuest != null ) + return !HasActiveQuestID( player, condition.notHasQuest ); + + // worldStateEquals: { key:"dl_ws_joinedthieves", value: 1 } + if( condition.worldStateEquals ) + { + var worldStateKey = condition.worldStateEquals.key; + var expectedWSValue = condition.worldStateEquals.value; + + return ( GetWorldStateInt( player, worldStateKey, 0 ) == ToIntOrZero( expectedWSValue )); + } + + // worldStateMin: { key:"dl_ws_ironreachrep", value: 10 } + if( condition.worldStateMin ) + { + var worldStateKeyMin = condition.worldStateMin.key; + var minWorldStateValue = ToIntOrZero( condition.worldStateMin.value ); + + return ( GetWorldStateInt( player, worldStateKeyMin, 0 ) >= minWorldStateValue ); + } + + // always: true (optional convenience) + if( condition.always ) + return true; + + return true; +} + +/** @type { ( player: Character, quest: any ) => number } */ +function ResolveNextQuestID( player, quest ) +{ + if( !quest ) + return 0; + + // Branching chain: nextQuest: [ { questID, cond }, ... ] + if( quest.nextQuest && quest.nextQuest.length ) + { + for( var i = 0; i < quest.nextQuest.length; i++ ) + { + var nextQuestRule = quest.nextQuest[i]; + if( !nextQuestRule ) + continue; + + if( QuestCondPasses( player, nextQuestRule.cond )) + { + var resolvedQuestID = parseInt( nextQuestRule.questID, 10 ); + if( !isNaN( resolvedQuestID ) && resolvedQuestID > 0 ) + return resolvedQuestID; + } + } + // No rule matched + return 0; + } + + // Old single-field fallback + if( quest.nextQuestID != null && quest.nextQuestID != 0 ) + { + var fallbackQuestID = parseInt( quest.nextQuestID, 10 ); + if( !isNaN( fallbackQuestID ) && fallbackQuestID > 0 ) + return fallbackQuestID; + } + + return 0; +} + +/** @type { ( creature: Character, player: Character ) => boolean } */ +function CreatureKilled( creature, player ) +{ + var questProgressArray = ReadQuestProgress( player ); + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + + // Ensure the quest belongs to the current player + if( questEntry.serial != player.serial ) + { + continue; + } + + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + + if( quest && ( quest.type == "kill" || quest.type == "timekills" || quest.type == "multi" ) && !questEntry.completed ) + { + for( var j = 0; j < quest.targetKills.length; j++ ) + { + var target = quest.targetKills[j]; + + if( target.npcID == creature.sectionID ) + { + UpdateQuestProgress( player, questEntry.questID, creature.sectionID, 1, "kill" ); + return true; + } + } + + return true; + } + } +} + +/** @type { ( player: Character, item: Item, isToggledOff?: boolean ) => void } */ +function ItemCollected( player, item, isToggledOff ) +{ + if( !ValidateObject( player )) + return; + + var socket = player.socket; + if( socket == null) + return; + + // Default the isToggledOff value + if( typeof isToggledOff == "undefined" ) + { + isToggledOff = false; + } + + var questProgressArray = ReadQuestProgress( player ); + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + + // Ensure the quest belongs to the current player + if( questEntry.serial != player.serial ) + { + continue; + } + + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + var isValidType = ( quest && ( quest.type == "collect" || quest.type == "timecollect" || quest.type == "multi" )); + + if( isValidType && ( isToggledOff || !questEntry.completed )) + { + for( var j = 0; j < quest.targetItems.length; j++ ) + { + var target = quest.targetItems[j]; + + + var questSectionID = item.GetTag( "QuestSectionID" ) || item.sectionID; + if( String( target.sectionID ) == String( item.sectionID ) || ( target.sectionID == questSectionID )) + { + var currentCount = questEntry.collectedItems[item.sectionID] || 0; + var remaining = target.amount - currentCount; + + if( isToggledOff ) + { + // Decrease the count when untoggled, ensuring it doesn't go below 0 + if( currentCount > 0 ) + { + var amountToRemove = Math.min( item.amount, currentCount ); + UpdateQuestProgress( player, questEntry.questID, item.sectionID, -amountToRemove, "collect" ); + + var questItemColor = item.GetTag( "saveColor" ); + if( questItemColor != null && !isNaN(parseInt(questItemColor)) ) + { + item.color = parseInt( questItemColor ); + } + else + { + item.color = 0; // fallback to default color (non-dyed) + } + item.isNewbie = false; + item.isDyeable = true; + item.SetTag( "QuestItem", null ); + item.SetTag( "QuestSectionID", null ); + item.RemoveScriptTrigger( 5806 ); // Quest Item script trigger + + socket.SysMessage( "You removed " + amountToRemove + " Quest Item(s)." ); + } + else + { + socket.SysMessage( GetDictionaryEntry( 19629, socket.language ));// Cannot decrease further. Current count is 0. + } + } + else + { + // Increase the count when toggled on + if( remaining > 0 ) + { + var amountToAdd = Math.min( item.amount, remaining ); + UpdateQuestProgress( player, questEntry.questID, item.sectionID, amountToAdd, "collect" ); + + item.SetTag( "saveColor", item.color ); + item.SetTag( "saveType", item.type ); + item.color = 0x04ea; // orange hue + item.isDyeable = false; + item.isNewbie = true; + item.SetTag( "QuestItem", true ); + item.SetTag( "QuestSectionID", target.sectionID ); + item.AddScriptTrigger( 5806 ); // Quest Item script trigger + + socket.SysMessage( "You set " + amountToAdd + " item(s) to Quest Item status." ); + } + else + { + socket.SysMessage( "Cannot collect more. Target amount reached: " + target.amount ); + } + } + return; // Exit after updating progress + } + } + } + } + + socket.SysMessage( "Item does not match any quest requirements." ); +} + +/** @type { ( pEquipper: Character, iEquipped: Item ) => boolean } */ +function EquipAttempt( pEquipper, iEquipped ) +{ + if( !ValidateObject( pEquipper )) + return false; + + var socket = pEquipper.socket; + if( socket == null) + return false; + + if( iEquipped.GetTag( "QuestItem" )) + { + socket.SysMessage( GetDictionaryEntry( 19630, socket.language )); // This item cannot be equipped as it is a quest item. + + var questProgressArray = ReadQuestProgress( pEquipper ); + var itemSectionID = iEquipped.sectionID; + var questSectionID = iEquipped.GetTag("QuestSectionID") || itemSectionID; + + for( var i = 0; i < questProgressArray.length; i++ ) + { + var questEntry = questProgressArray[i]; + + if( questEntry.serial != pEquipper.serial ) + continue; + + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + var isValidType = ( quest && ( quest.type == "collect" || quest.type == "timecollect" || quest.type == "multi" )); + + if( !isValidType ) + continue; + + for( var j = 0; j < quest.targetItems.length; j++ ) + { + var target = quest.targetItems[j]; + + // Match by tag or item.sectionID + if( target.sectionID == questSectionID || target.sectionID == itemSectionID ) + { + var currentCount = questEntry.collectedItems[itemSectionID] || 0; + + if( currentCount > 0 ) + { + var amountToRemove = Math.min( iEquipped.amount, currentCount ); + UpdateQuestProgress( pEquipper, questEntry.questID, itemSectionID, -amountToRemove, "collect" ); + + var questItemColor = iEquipped.GetTag( "saveColor" ); + if( questItemColor != null && !isNaN(parseInt(questItemColor)) ) + { + iEquipped.color = parseInt( questItemColor ); + } + else + { + iEquipped.color = 0; // fallback to default color (non-dyed) + } + + iEquipped.isNewbie = false; + iEquipped.isDyeable = true; + iEquipped.SetTag( "QuestItem", null ); + iEquipped.SetTag( "QuestSectionID", null ); + iEquipped.RemoveScriptTrigger( 5806 ); + iEquipped.Refresh(); + + socket.SysMessage( "You removed Quest Item status from the item." ); + } + else + { + socket.SysMessage( GetDictionaryEntry( 19629, socket.language )); // Cannot decrease further. Current count is 0. + } + } + } + } + return false; + } + return true; +} + +/** @type { ( pPlayer: Character, skill: number, skillGainAmount: number ) => boolean } */ +function AccelerateSkillGain( pPlayer, skill, skillGainAmount ) +{ + var activeQuests = ReadQuestProgress( pPlayer ); + + for( var i = 0; i < activeQuests.length; i++ ) + { + var questEntry = activeQuests[i]; + + // Ensure the quest entry matches the player's serial + if( questEntry.serial != pPlayer.serial ) + { + continue; + } + + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + + if( quest && quest.type == "skillgain" && !questEntry.completed ) + { + if( pPlayer.region.id == quest.targetRegion && skill == quest.targetSkill ) + { + var currentSkillPoints = pPlayer.baseskills[skill]; + var acceleratedGain = RandomNumber( quest.minPoint, quest.MaxPoint ); + + if(( currentSkillPoints + acceleratedGain ) >= quest.maxSkillPoints ) + { + UpdateQuestProgress( pPlayer, questEntry.questID, skill, quest.maxSkillPoints - currentSkillPoints, "skillgain" ); + } + else + { + // Apply accelerated skill gain and update progress + pPlayer.AddSkill( skill, acceleratedGain, false ); + UpdateQuestProgress( pPlayer, questEntry.questID, skill, acceleratedGain, "skillgain" ); + } + + return true; + } + } + } + + return true; +} + +/** @type { ( skillID: number ) => string } */ +function GetSkillName( skillID ) +{ + var skillNames = [ + "alchemy", "anatomy", "animallore", "itemid", "armslore", "parrying", "begging", + "blacksmith", "bowcraft", "peacemaking", "camping", "carpentry", "cartography", + "cooking", "detectinghidden", "enticement", "evaluatingintelligence", "healing", + "fishing", "forensics", "herding", "hiding", "provocation", "inscription", + "lockpicking", "magery", "magicresistance", "tactics", "snooping", "musicianship", "poisoning", + "archery", "spiritSpeak", "stealing", "tailoring", "animaltaming", "tasteID", + "tinkering", "tracking", "veterinary", "swordsmanship", "macefighting", + "fencing", "wrestling", "lumberjacking", "mining", "meditation", "stealth", + "removetrap", "necromancy", "focus", "chivalry", "bushido", "ninjitsu", "spellweaving" + ]; + + return skillNames[skillID] || "unknown skill"; +} + +// Map common aliases -> canonical keys used by baseskills/skillCaps +var skillAliasMap = { + // combat + "swords": "swordsmanship", "swordsmanship": "swordsmanship", + "mace": "macefighting", "macing": "macefighting", "macefighting": "macefighting", + "fencing": "fencing", "wrestling": "wrestling", "archery": "archery", + // magic + "magery": "magery", "evalint": "evaluatingintelligence", "evaluating intelligence": "evaluatingintelligence", + "evaluatingintelligence": "evaluatingintelligence", "meditation": "meditation", + "resist": "magicresistance", "resistingspells": "magicresistance", "magicresistance": "magicresistance", + // crafts & misc (just a few common ones; names already match) + "tactics": "tactics", "anatomy": "anatomy", "healing": "healing", + "blacksmith": "blacksmith", "carpentry": "carpentry", "tinkering": "tinkering", + "tailoring": "tailoring", "inscription": "inscription", "musicianship": "musicianship", + "stealth": "stealth", "hiding": "hiding", "provocation": "provocation", "peacemaking": "peacemaking", + "cartography": "cartography", "mining": "mining", "lumberjacking": "lumberjacking", + // newer + "chivalry": "chivalry", "bushido": "bushido", "ninjitsu": "ninjitsu", "spellweaving": "spellweaving" +}; + +/** @type { ( skill: string | number ) => string } */ +function NormalizeKey( skill ) +{ + return String( skill || "" ).replace( /\s+/g, "" ).toLowerCase(); +} + +/** @type { ( skillNameOrId: string | number ) => string | null } */ +function resolveSkillKey( skillNameOrId ) +{ + var skillNumber = parseInt( skillNameOrId, 10 ); + if( !isNaN( skillNumber )) + { + var getSkill = GetSkillName(skillNumber); + return ( getSkill && getSkill !== "unknown skill" ) ? getSkill : null; + } + + var key = NormalizeKey( skillNameOrId ); + // exact match first + if( skillAliasMap.hasOwnProperty( key )) + return skillAliasMap[key]; + // if caller already used canonical (e.g. "magery"), allow it + return key || null; +} + +/** @type { ( player: Character, key: string ) => number } */ +function GetBaseSkillTenths( player, key ) +{ + // baseskills.* is 0..1000 + var value = player.baseskills[key]; + return ( typeof value === "number" ) ? ( value | 0 ) : 0; +} + +/** @type { ( player: Character, key: string, valTenths: number ) => void } */ +function SetBaseSkillTenths( player, key, valTenths ) +{ + player.baseskills[key] = ( valTenths | 0 ); +} + +/** @type { ( player: Character, key: string ) => number } */ +function GetSkillCapTenths( player, key ) +{ + var cap = player.skillCaps[key]; + // fallback to 100.0 if undefined/not numeric + return ( typeof cap === "number" && cap > 0 ) ? ( cap | 0 ) : 1000; +} + +/** @type { ( player: Character, reward: any, socket: Socket ) => void } */ +function SkillReward( player, reward, socket ) +{ + var key = resolveSkillKey( reward.skill ); + if( !key || player.baseskills[key] === undefined ) + { + socket.SysMessage( "Quest reward error: Unknown skill '" + reward.skill + "'." ); + return; + } + + var addTenths = Math.round(( +reward.amount || 0 ) * 10 ); + if( addTenths === 0 ) + { + socket.SysMessage( "Quest reward notice: 0 skill points specified for " + reward.skill + "." ); + return; + } + + var cur = GetBaseSkillTenths( player, key ); + var cap = GetSkillCapTenths( player, key ); + var nxt = Math.max( 0, Math.min( cur + addTenths, cap )); + + SetBaseSkillTenths( player, key, nxt ); + + var gained = ( nxt - cur ) / 10.0; + if( gained > 0 ) + socket.SysMessage( "You gained " + gained.toFixed(1) + " in " + key + "!" ); + else + socket.SysMessage( "Your " + key + " is already at its cap." ); +} + +// Optional pooled points (stored in tenths under 'UnspentSkillPoints' tag) +/** @type { ( player: Character, reward: any, socket: Socket ) => void } */ +function SkillPointsPoolReward( player, reward, socket ) +{ + var addTenths = Math.round(( +reward.amount || 0 ) * 10 ); + if( addTenths <= 0 ) + { + socket.SysMessage( "Quest reward notice: 0 pooled skill points specified." ); + return; + } + + var cur = parseInt( player.GetTag( "UnspentSkillPoints" ), 10 ) || 0; + player.SetTag( "UnspentSkillPoints", String( cur + addTenths )); + socket.SysMessage( "You received " + ( addTenths / 10 ).toFixed( 1 ) + " unspent skill points!" ); +} + +// Spend pooled points into a specific skill +/** @type { ( player: Character, socket: Socket, skillNameOrId: string | number, amount: number ) => void } */ +function SpendSkillPoints( player, socket, skillNameOrId, amount ) +{ + var key = resolveSkillKey( skillNameOrId ); + if( !key || player.baseskills[key] === undefined ) + { + socket.SysMessage( "Unknown skill: " + skillNameOrId ); + return; + } + + var pool = parseInt(player.GetTag( "UnspentSkillPoints" ), 10 ) || 0; + var spendTenths = Math.min( pool, Math.round(( +amount || 0 ) * 10 )); + if( spendTenths <= 0 ) + { + socket.SysMessage( "No unspent points to spend." ); + return; + } + + var cur = GetBaseSkillTenths( player, key ); + var cap = GetSkillCapTenths( player, key ); + var room = Math.max( 0, cap - cur ); + var applied = Math.min( room, spendTenths ); + if( applied <= 0 ) + { + socket.SysMessage( "That skill is already at its cap." ); + return; + } + + SetBaseSkillTenths( player, key, cur + applied ); + player.SetTag( "UnspentSkillPoints", String(pool - applied )); + socket.SysMessage( "Allocated " + ( applied / 10 ).toFixed( 1 ) + " points to " + key + "." ); +} + +////////////////////////////////////////////////////////////////////////////////// +// Save/Read Functions // +////////////////////////////////////////////////////////////////////////////////// + +/** @type { ( player: Character ) => any } */ +function ReadPlayerSettings( player ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var fileName = "PlayerSettings_" + userAccount.id + ".jsdata"; + + var settings = {}; + + mFile.Open( fileName, "r", "Settings" ); + if( mFile && mFile.Length() > 0 ) + { + while( !mFile.EOF() ) + { + var line = manualTrim( mFile.ReadUntil( "\n" )); // Use manualTrim instead of trim + if( line == "" ) + continue; // Skip empty lines + + var parts = line.split("="); + if( parts.length == 2 ) + { + var key = manualTrim( parts[0] ); // Use manualTrim on the key + var value = manualTrim( parts[1] ); // Use manualTrim on the value + + // Check if the value is a quoted string + if( value.charAt( 0 ) == '"' && value.charAt( value.length - 1 ) == '"' ) + { + // Remove quotes and unescape internal quotes + settings[key] = value.substring( 1, value.length - 1 ).replace( /\\"/g, '"' ); + } + else if( value == "1" || value == "0" ) + { + // Convert "1"/"0" to boolean true/false + settings[key] = value == "1"; + } + else if( !isNaN( value )) + { + // Convert numeric strings to numbers + settings[key] = parseFloat( value ); + } + else + { + // Keep as string for other cases + settings[key] = value; + } + } + } + mFile.Close(); + mFile.Free(); + } + else + { + if( mFile ) + { + mFile.Free(); + } + } + + return settings; // Return parsed settings +} + +/** @type { ( player: Character, settings: any ) => boolean } */ +function SavePlayerSettings( player, settings ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var fileName = "PlayerSettings_" + userAccount.id + ".jsdata"; + + mFile.Open( fileName, "w", "Settings" ); + if( mFile ) + { + for( var key in settings ) + { + if( settings.hasOwnProperty( key )) + { + var value = settings[key]; + + // Serialize based on data type + if( typeof value == "string" ) + { + mFile.Write( key + "=" + '"' + value.replace( /"/g, '\\"' ) + '"' + "\n" ); // Escape quotes for strings + } + else if( typeof value == "boolean" ) + { + mFile.Write( key + "=" + ( value ? "1" : "0" ) + "\n" ); // Boolean as 1/0 + } + else if( !isNaN(value )) + { + mFile.Write( key + "=" + value + "\n" ); // Numbers directly + } + else + { + // Fallback for unsupported data types + mFile.Write( key + "=" + ( value || "undefined" ) + "\n" ); + } + } + } + mFile.Close(); + mFile.Free(); + return true; // Save succeeded + } + + return false; // Failed to save settings +} + +/** @type { ( player: Character, failedQuest: any ) => void } */ +function LogFailedQuest( player, failedQuest ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var failedLogFileName = "FailedQuests_" + userAccount.id + ".jsdata"; + + mFile.Open( failedLogFileName, "a", "Quests" ); // Append mode + if( mFile ) + { + // Serialize collectedItems + var collectedItemsStr = ""; + if( failedQuest.collectedItems ) + { + for( var key in failedQuest.collectedItems ) + { + if( failedQuest.collectedItems.hasOwnProperty( key )) + { + if( collectedItemsStr.length > 0 ) + { + collectedItemsStr += ","; + } + collectedItemsStr += key + ":" + failedQuest.collectedItems[key]; + } + } + } + + // Serialize harvestKills + var harvestKillsStr = ""; + if( failedQuest.harvestKills ) + { + for( var key in failedQuest.harvestKills ) + { + if( failedQuest.harvestKills.hasOwnProperty( key )) + { + if( harvestKillsStr.length > 0 ) + { + harvestKillsStr += ","; + } + harvestKillsStr += key + ":" + failedQuest.harvestKills[key]; + } + } + } + + // Write the failed quest details + var failedEntry = + "Serial=" + ( failedQuest.serial || "undefined" ) + "\n" + + "QuestID=" + ( failedQuest.questID || "undefined" ) + "\n" + + "QuestProgress=" + ( failedQuest.questProgress || 0 ) + "\n" + + "HarvestKills=" + harvestKillsStr + "\n" + + "CollectedItems=" + collectedItemsStr + "\n" + + "StartTime=" + ( failedQuest.startTime || 0 ) + "\n" + + "TimeLimit=" + ( failedQuest.timeLimit || 0 ) + "\n" + + "Completed=0\n" + + "QuestTurnIn=0\n\n"; + "Failed=1\n" + + + mFile.Write( failedEntry ); + mFile.Close(); + mFile.Free(); + } +} + +/** @type { ( player: Character ) => number[] } */ +function ReadFailedQuests( player ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var failedFileName = "FailedQuests_" + userAccount.id + ".jsdata"; + + var failedQuests = []; + mFile.Open( failedFileName, "r", "Quests" ); + if( mFile && mFile.Length() >= 0 ) + { + var currentQuestID = null; + var currentSerial = null; + + while( !mFile.EOF() ) + { + var line = mFile.ReadUntil( "\n" ); + line = manualTrim( line ); // Remove leading/trailing spaces + + // Skip empty lines + if( line == "" ) + { + continue; + } + + // Split the line into key and value + var parts = line.split( "=" ); + if( parts.length == 2 ) + { + var key = manualTrim( parts[0] ).toLowerCase(); + var value = manualTrim( parts[1] ); + + if( key == "questid" ) + { + currentQuestID = parseInt( value, 10 ); // Parse the QuestID + } + else if( key == "serial" ) + { + currentSerial = parseInt( value, 10 ); // Parse the Serial + } + + // If we reach the end of an entry and Serial matches the player, save the QuestID + if( key == "failed" && value == "1" && currentQuestID != null && currentSerial == player.serial ) + { + failedQuests.push( currentQuestID ); + currentQuestID = null; // Reset for next entry + currentSerial = null; // Reset for next entry + } + } + } + mFile.Close(); + mFile.Free(); + } + else + { + player.SysMessage( "No failed quests found." ); + } + + return failedQuests; +} + +/** @type { ( player: Character, completedQuest: any ) => void } */ +function ArchiveCompletedQuest( player, completedQuest ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var archiveFileName = "QuestArchive_" + userAccount.id + ".jsdata"; + + var quest = TriggerEvent( 5801, "QuestList", completedQuest.questID ); // Fetch the quest details + var nextQuestID = quest ? quest.nextQuestID || "null" : "null"; // Safely resolve nextQuestID + + mFile.Open( archiveFileName, "a", "Quests" ); // Append mode + if( mFile ) + { + // Serialize collectedItems + var collectedItemsStr = ""; + if( completedQuest.collectedItems ) + { + for( var key in completedQuest.collectedItems ) + { + if( completedQuest.collectedItems.hasOwnProperty( key )) + { + if( collectedItemsStr.length > 0 ) + { + collectedItemsStr += ","; + } + collectedItemsStr += key + ":" + completedQuest.collectedItems[key]; + } + } + } + + // Serialize harvestKills + var harvestKillsStr = ""; + if( completedQuest.harvestKills ) + { + for( var key in completedQuest.harvestKills ) + { + if( completedQuest.harvestKills.hasOwnProperty( key )) + { + if( harvestKillsStr.length > 0 ) + { + harvestKillsStr += ","; + } + harvestKillsStr += key + ":" + completedQuest.harvestKills[key]; + } + } + } + + // Add skill progress + var skillProgressStr = ""; + if( quest && quest.type == "skillgain" ) + { + skillProgressStr = "SkillProgress=" + ( completedQuest.skillProgress || 0 ) + "\n" + + "MaxSkillPoints=" + ( quest.maxSkillPoints || 0 ) + "\n" + + "TargetSkill=" + ( quest.targetSkill || "null" ) + "\n"; + } + + // Add delivery progress + var deliveryProgressStr = ""; + if( quest && quest.type == "delivery" ) + { + deliveryProgressStr = "DeliveryProgress=" + (completedQuest.deliveryProgress || 0 ) + "\n" + + "DeliveryItem=" + ( completedQuest.deliveryItem || "null" ) + "\n" + + "TargetDeliveryNPC=" + ( completedQuest.targetDeliveryNPC || "null" ) + "\n"; + } + + // Write the serialized data to the archive file + var archiveEntry = + "Serial=" + ( completedQuest.serial || "undefined" ) + "\n" + + "QuestID=" + ( completedQuest.questID || "undefined" ) + "\n" + + "NextQuestID=" + nextQuestID + "\n" + // Save the next quest in the chain + "LastCompleted=" + Date.now() + "\n" + // Record completion time + "QuestProgress=" + ( completedQuest.questProgress || 0 ) + "\n" + + "CollectedItems=" + collectedItemsStr + "\n" + + "HarvestKills=" + harvestKillsStr + "\n" + + skillProgressStr + // Add skill progress + deliveryProgressStr + // Add delivery progress + "StartTime=" + ( completedQuest.startTime || 0 ) + "\n" + // Save startTime + "TimeLimit=" + ( completedQuest.timeLimit || 0 ) + "\n" + // Save timeLimit + "Completed=1\n" + + "QuestTurnIn=1\n\n"; + + mFile.Write( archiveEntry ); + mFile.Close(); + mFile.Free(); + } +} + +/** @type { ( player: Character ) => number[] } */ +function ReadArchivedQuests( player ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var archiveFileName = "QuestArchive_" + userAccount.id + ".jsdata"; + + var archivedQuests = []; + mFile.Open( archiveFileName, "r", "Quests" ); + if( mFile && mFile.Length() >= 0 ) + { + var currentQuestID = null; + var currentSerial = null; + + while( !mFile.EOF() ) + { + var line = mFile.ReadUntil( "\n" ); + line = manualTrim( line ); // Remove leading/trailing spaces + + // Skip empty lines + if( line == "" ) + { + // Check if we reached the end of an entry + if( currentQuestID != null && currentSerial == player.serial ) + { + archivedQuests.push( currentQuestID ); // Add the quest ID + } + currentQuestID = null; // Reset for the next entry + currentSerial = null; // Reset for the next entry + continue; + } + + // Split the line into key and value + var parts = line.split( "=" ); + if( parts.length == 2 ) + { + var key = manualTrim( parts[0] ).toLowerCase(); + var value = manualTrim( parts[1] ); + + if( key == "questid" ) + { + currentQuestID = parseInt( value, 10 ); // Parse quest ID + } + else if( key == "serial" ) + { + currentSerial = parseInt( value, 10 ); // Parse player serial + } + + // Add quest ID if marked as completed + if( key == "completed" && value == "1" && currentQuestID != null && currentSerial == player.serial ) + { + archivedQuests.push( currentQuestID ); + } + } + } + + mFile.Close(); + mFile.Free(); + } + + return archivedQuests; // Return array of quest IDs +} + +/** @type { ( player: Character, questProgressArray: any[] ) => boolean } */ +function WriteQuestProgress( player, questProgressArray ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var fileName = "QuestProgress_" + userAccount.id + ".jsdata"; + + mFile.Open( fileName, "w", "Quests" ); + if( mFile ) + { + for( var i = 0; i < questProgressArray.length; i++ ) + { + var progressEntry = questProgressArray[i]; + + // Serialize kills + var killsStr = ""; + if( progressEntry.harvestKills ) + { + for( var killKey in progressEntry.harvestKills ) + { + if( progressEntry.harvestKills.hasOwnProperty( killKey )) + { + if( killsStr.length > 0 ) + { + killsStr += ","; + } + killsStr += killKey + ":" + progressEntry.harvestKills[killKey]; + } + } + } + + // Serialize items + var collectedItemsStr = ""; + if( progressEntry.collectedItems ) + { + for( var itemKey in progressEntry.collectedItems ) + { + if( progressEntry.collectedItems.hasOwnProperty( itemKey )) + { + if( collectedItemsStr.length > 0 ) + { + collectedItemsStr += ","; + } + collectedItemsStr += itemKey + ":" + progressEntry.collectedItems[itemKey]; + } + } + } + + // Write all required fields + var formattedEntry = + "Serial=" + ( progressEntry.serial || "undefined" ) + "\n" + + "QuestID=" + ( progressEntry.questID || "undefined" ) + "\n" + + "QuestProgress=" + ( progressEntry.questProgress || 0 ) + "\n" + + "HarvestKills=" + killsStr + "\n" + + "CollectedItems=" + collectedItemsStr + "\n" + + "SkillProgress=" + ( progressEntry.skillProgress || 0 ) + "\n" + // Skill progress + "TargetSkill=" + ( progressEntry.targetSkill || -1 ) + "\n" + // Target skill ID + "TargetRegion=" + ( progressEntry.targetRegion || 0 ) + "\n" + // Target region ID + "MaxSkillPoints=" + ( progressEntry.maxSkillPoints || 50.0 ) + "\n" + // Max skill points + "StartTime=" + ( progressEntry.startTime || 0 ) + "\n" + // Start time + "TimeLimit=" + ( progressEntry.timeLimit || 0 ) + "\n" + // Time limit + "LastAccepted=" + ( progressEntry.lastAccepted || 0 ) + "\n" + // Save the lastAccepted timestamp + "Completed=" + ( progressEntry.completed ? "1" : "0" ) + "\n" + + "QuestTurnIn=" + ( progressEntry.questTurnIn ? "1" : "0" ) + "\n" + + "NextQuestID=" + ( progressEntry.nextQuestID || "undefined" ) + "\n\n"; + mFile.Write( formattedEntry ); + } + mFile.Close(); + mFile.Free(); + return true; + } + return false; +} + +/** @type { ( player: Character ) => any[] } */ +function ReadQuestProgress( player ) +{ + var mFile = new UOXCFile(); + var userAccount = player.account; + var fileName = "QuestProgress_" + userAccount.id + ".jsdata"; + + var questProgressArray = []; + mFile.Open( fileName, "r", "Quests" ); + + if( mFile && mFile.Length() >= 0 ) + { + var currentEntry = null; + while( !mFile.EOF() ) + { + var line = mFile.ReadUntil( "\n" ); + + // Normalize line: Remove unexpected characters + line = manualTrim( line.replace( /[^\x20-\x7E]/g, "" )); + + if( line == "" ) + { + if( currentEntry ) + { + finalizeQuestEntry( currentEntry, player ); + questProgressArray.push( currentEntry ); + currentEntry = null; + } + continue; + } + + var parts = line.split( "=" ); + if( parts.length == 2 ) + { + if( !currentEntry ) + currentEntry = {}; + + var key = manualTrim( parts[0] ).toLowerCase(); // Convert key to lowercase + var value = manualTrim( parts[1] ); + + currentEntry[key] = value; + } + } + + // Handle the last entry + if( currentEntry ) + { + finalizeQuestEntry( currentEntry, player ); + questProgressArray.push( currentEntry ); + } + + mFile.Close(); + mFile.Free(); + } + return questProgressArray; +} + +/** @type { ( entry: any, player: Character ) => void } */ +function finalizeQuestEntry( entry, player ) +{ + entry.serial = parseInt( entry.serial || "0", 10 ); + if( isNaN( entry.serial ) ) + entry.serial = 0; + entry.questID = parseInt( entry.questid || "0", 10 ); + entry.completed = entry.completed == "1"; + entry.questTurnIn = entry.questturnin == "1"; + entry.startTime = parseInt( entry.starttime || "0", 10 ); + entry.timeLimit = parseInt( entry.timelimit || "0", 10 ); + entry.skillProgress = parseFloat( entry.skillprogress || "0.0" ); + entry.targetSkill = parseInt( entry.targetskill || "-1", 10 ); + entry.targetRegion = parseInt( entry.targetregion || "0", 10 ); + entry.maxSkillPoints = parseFloat( entry.maxskillpoints || "50.0" ); + entry.lastAccepted = parseInt( entry.lastaccepted || "0", 10 ); // Add lastAccepted timestamp + + processCollectedItems(entry, player); + processKills(entry, player); +} + +/** @type { ( entry: any, player: Character ) => void } */ +function processCollectedItems( entry, player ) +{ + entry.collectedItems = {}; + var collectedItemsStr = entry.collecteditems || ""; // Use consistent key + + if( collectedItemsStr == "" ) + { + return; + } + + var collectedItems = collectedItemsStr.split( "," ); + for( var i = 0; i < collectedItems.length; i++ ) + { + var pair = collectedItems[i].split( ":" ); + if( pair.length == 2 ) + { + var key = manualTrim( pair[0] ); + var value = parseInt( manualTrim( pair[1] ), 10 ); + entry.collectedItems[key] = value; + } + } +} + +/** @type { ( entry: any, player: Character ) => void } */ +function processKills( entry, player ) +{ + // Initialize harvestKills as an object if not already + entry.harvestKills = {}; + + // Use the correct key to extract the raw kills string + var killsStr = entry.harvestkills || ""; // Note: lowercase to match file format + + // If killsStr is empty, there�s nothing to process + if( killsStr == "" ) + { + return; + } + + var harvestKills = killsStr.split( "," ); + for( var i = 0; i < harvestKills.length; i++ ) + { + var pair = harvestKills[i].split( ":" ); + if( pair.length == 2 ) + { + var key = manualTrim( pair[0] ); + var value = parseInt( manualTrim( pair[1] ), 10 ); + entry.harvestKills[key] = value; + } + } +} + +/** @type { ( str: string ) => string } */ +function manualTrim( str ) +{ + return str.replace( /^\s+|\s+$/g, "" ); +} + +////////////////////////////////////////////////////////////////////////////////// +// World State (file-backed) // +////////////////////////////////////////////////////////////////////////////////// + +// PlayerSettings key used for world state blob (PER-CHAR by serial) +/** @type { ( player: Character ) => string } */ +function GetWorldStateKey( player ) +{ + // Stored inside the account-based PlayerSettings file, but keyed per character. + return "WorldState_" + player.serial; +} + +/** @type { ( player: Character ) => Record } */ +function ReadWorldState( player ) +{ + var playerSettings = ReadPlayerSettings( player ) || {}; + var worldStateKey = GetWorldStateKey( player ); + var rawBlob = playerSettings[worldStateKey]; + + // PlayerSettings parses quoted strings, so rawBlob is either string or undefined + if( typeof rawBlob != "string" || rawBlob == "" ) + return {}; + + return ParseWorldStateBlob( rawBlob ); +} + +/** @type { ( player: Character, worldState: Record ) => boolean } */ +function SaveWorldState( player, worldState ) +{ + var playerSettings = ReadPlayerSettings( player ) || {}; + var worldStateKey = GetWorldStateKey( player ); + + playerSettings[worldStateKey] = SerializeWorldStateBlob( worldState ); + return SavePlayerSettings( player, playerSettings ); +} + +/** @type { ( player: Character, stateKey: string, defaultValue?: number ) => number } */ +function GetWorldStateInt( player, stateKey, defaultValue ) +{ + var worldState = ReadWorldState( player ); + var normalizedKey = NormalizeWSKey( stateKey ); + + if( worldState.hasOwnProperty( normalizedKey ) ) + return ToIntOrZero( worldState[normalizedKey] ); + + return ToIntOrZero( defaultValue ); +} + +/** @type { ( player: Character, stateKey: string, value: number | null | undefined ) => boolean } */ +function SetWorldStateInt( player, stateKey, value ) +{ + var worldState = ReadWorldState( player ); + var normalizedKey = NormalizeWSKey( stateKey ); + + // null/undefined => remove key + if( value === null || value === undefined ) + { + if( worldState.hasOwnProperty( normalizedKey ) ) + delete worldState[normalizedKey]; + + return SaveWorldState( player, worldState ); + } + + worldState[normalizedKey] = ToIntOrZero( value ); + return SaveWorldState( player, worldState ); +} + +/** @type { ( player: Character, stateKey: string, delta: number, min?: number | null, max?: number | null ) => number } */ +function AddWorldStateDelta( player, stateKey, delta, min, max ) +{ + var currentValue = GetWorldStateInt( player, stateKey, 0 ); + var nextValue = currentValue + ToIntOrZero( delta ); + + if( min != null && nextValue < min ) nextValue = min; + if( max != null && nextValue > max ) nextValue = max; + + SetWorldStateInt( player, stateKey, nextValue ); + return nextValue; +} + +/** @type { ( stateKey: string ) => string } */ +function NormalizeWSKey( stateKey ) +{ + // Keep it simple + stable. Lowercase prevents duplicates. + return String( stateKey || "" ).replace( /^\s+|\s+$/g, "" ).toLowerCase(); +} + +/** @type { ( rawBlob: string ) => Record } */ +function ParseWorldStateBlob( rawBlob ) +{ + var worldState = {}; + rawBlob = String( rawBlob || "" ); + + if( rawBlob == "" ) + return worldState; + + var pairs = rawBlob.split( "," ); + for( var i = 0; i < pairs.length; i++ ) + { + var pairText = manualTrim( pairs[i] ); + if( pairText == "" ) + continue; + + var keyValuePair = pairText.split( ":" ); + if( keyValuePair.length != 2 ) + continue; + + var normalizedKey = NormalizeWSKey( keyValuePair[0] ); + if( normalizedKey == "" ) + continue; + + var valueInt = parseInt( manualTrim( keyValuePair[1] ), 10 ); + if( isNaN( valueInt ) ) + valueInt = 0; + + worldState[normalizedKey] = valueInt; + } + + return worldState; +} + +/** @type { ( worldState: Record ) => string } */ +function SerializeWorldStateBlob( worldState ) +{ + if( !worldState ) + return ""; + + // stable order (so diffs are clean) + var normalizedKeys = []; + for( var key in worldState ) + { + if( worldState.hasOwnProperty( key ) ) + { + var normalizedKey = NormalizeWSKey( key ); + if( normalizedKey != "" ) + normalizedKeys.push( normalizedKey ); + } + } + normalizedKeys.sort(); + + var outBlob = ""; + for( var i = 0; i < normalizedKeys.length; i++ ) + { + var normalizedKey = normalizedKeys[i]; + var valueInt = ToIntOrZero( worldState[normalizedKey] ); + + if( outBlob.length > 0 ) outBlob += ","; + outBlob += normalizedKey + ":" + valueInt; + } + + return outBlob; +} \ No newline at end of file diff --git a/data/js/server/quest/quest_dialogue_gump.js.js b/data/js/server/quest/quest_dialogue_gump.js.js new file mode 100644 index 000000000..000b5d806 --- /dev/null +++ b/data/js/server/quest/quest_dialogue_gump.js.js @@ -0,0 +1,321 @@ +/// +// @ts-check + +function onTalk( pTalking, strSaid ) +{ + if( !ValidateObject( pTalking )) + return true; + + var sock = pTalking.socket; + if( sock == null ) + return true; + + // Normalize input + var s = ("" + strSaid); + s = s.replace(/^\s+|\s+$/g, ""); + var low = s.toLowerCase(); + + // Example keyword trigger: "'garrick" or "garrick" + // You can pick your own rules here. + if( low == "garrick" || low == "'garrick" || low.indexOf("garrick") != -1 ) + { + // Find the NPC nearby (optional but recommended) + var npc = FindNearbyNpcByName( pTalking, "garrick" ); + if( ValidateObject( npc )) + { + // Start tree + TriggerEvent( 7510, "OpenDialogueTree", pTalking, npc, "blacksmith_tutorial", null ); + + // Prevent speech showing + block hardcode + return false; + } + + // If no NPC found, let speech show + return true; + } + + return true; +} + +// Simple nearby scan. Adjust radius if you want. +function FindNearbyNpcByName( pUser, nameLower ) +{ + // This uses AreaCharacterFunction pattern: it calls a callback for chars in radius. + // If your server build does not have AreaCharFunction, tell me and I will swap it + // to a different scan method you do have. + pUser.SetTempTag( "DLG_FindNpcResult", "0" ); + AreaCharacterFunction( "DLG_FindNpc_Callback", pUser, 10, nameLower ); + + var ser = parseInt( pUser.GetTempTag( "DLG_FindNpcResult" ), 10 ); + if( isNaN( ser ) || ser <= 0 ) + return null; + + var c = CalcCharFromSer( ser ); + if( ValidateObject( c )) + return c; + + return null; +} + +function DLG_FindNpc_Callback( srcChar, trgChar, socket, nameLower ) +{ + // trgChar is the char found in area + if( !ValidateObject( trgChar ) || !ValidateObject( srcChar )) + return false; + + // Only NPCs + if( trgChar.npc != 1 ) + return false; + + var n = ("" + trgChar.name).toLowerCase(); + if( n.indexOf( nameLower ) != -1 ) + { + srcChar.SetTempTag( "DLG_FindNpcResult", "" + trgChar.serial ); + return true; // stop scanning if AreaCharFunction respects return true as "found" + } + + return false; +} + +// ------------------------------------------------------------ +// Entry point +// ------------------------------------------------------------ +function OpenDialogueTree( pUser, pNpc, treeID, nodeId ) +{ + if( !ValidateObject( pUser )) + return; + + var socket = pUser.socket; + if( socket == null ) + return; + + var tree = TriggerEvent( 0, "GetDialogueTree_Trigger", treeID ); // If you register TriggerEvent for it + // If you are not using TriggerEvent, call GetDialogueTree(treeID) directly. + + if( !tree ) + { + socket.SysMessage( "Dialogue tree not found: " + treeID ); + return; + } + + nodeId = nodeId || tree.startNode; + var node = tree.nodes[nodeId]; + if( !node ) + { + socket.SysMessage( "Dialogue node not found: " + nodeId ); + return; + } + + // Save context so onGumpPress can continue the conversation + pUser.SetTempTag( "DLG_TreeID", String( treeID )); + pUser.SetTempTag( "DLG_NodeID", String( nodeId )); + pUser.SetTempTag( "DLG_NpcSerial", ValidateObject(pNpc) ? ("" + pNpc.serial) : "0" ); + + var g = new Gump(); + g.AddPage( 0 ); + g.NoClose(); + + g.AddBackground( 30, 100, 360, 420, 1579 ); + g.AddGump( 55, 110, 1577 ); + + // Title + g.AddHTMLGump( 55, 135, 310, 25, true, false, "
" + (tree.title || "Conversation") + "
" ); + + // Node text + g.AddHTMLGump( 55, 165, 310, 180, true, true, node.text || "" ); + + // Choices area + var choices = node.choices || []; + var y = 360; + var shown = 0; + + for( var i = 0; i < choices.length && shown < DLG_MAX_CHOICES; i++ ) + { + var c = choices[i]; + if( !ChoiceAllowed( pUser, c )) + continue; + + // button id: 100 + indexInNode + g.AddButton( 55, y, 0x2EE0, 0x2EE2, 1, 0, 100 + i ); + g.AddText( 90, y + 2, 0, c.text || "..." ); + + y += 26; + shown++; + } + + // Close button + g.AddButton( 300, 470, 0x2EEC, 0x2EEE, 1, 0, 1 ); + g.AddText( 325, 472, 0, "Close" ); + + g.Send( socket ); + g.Free(); +} + +// ------------------------------------------------------------ +// Conditions +// ------------------------------------------------------------ +function ChoiceAllowed( pUser, choice ) +{ + if( !choice || !choice.cond ) + return true; + + var cond = choice.cond; + + // tagEquals + if( cond.tagEquals ) + { + var t = cond.tagEquals.tag; + var v = cond.tagEquals.value; + + // UOX3 GetTag returns 0 if missing + var cur = pUser.GetTag( t ); + return ( String(cur) == String(v) ); + } + + // tagMin + if( cond.tagMin ) + { + var t2 = cond.tagMin.tag; + var minV = cond.tagMin.value; + + var cur2 = pUser.GetTag( t2 ); + cur2 = parseInt( cur2, 10 ); + if( isNaN(cur2) ) cur2 = 0; + + return ( cur2 >= minV ); + } + + // hasQuest + if( cond.hasQuest ) + { + return HasQuestActive( pUser, cond.hasQuest ); + } + + // completedQuest + if( cond.completedQuest ) + { + return HasQuestCompleted( pUser, cond.completedQuest ); + } + + return true; +} + +function HasQuestActive( pUser, questID ) +{ + var progress = TriggerEvent( 5800, "ReadQuestProgress", pUser ) || []; + for( var i = 0; i < progress.length; i++ ) + { + if( progress[i].serial == pUser.serial && progress[i].questID == questID ) + return true; + } + return false; +} + +function HasQuestCompleted( pUser, questID ) +{ + var archived = TriggerEvent( 5800, "ReadArchivedQuests", pUser ) || []; + for( var i = 0; i < archived.length; i++ ) + { + if( parseInt( archived[i], 10 ) == questID ) + return true; + } + return false; +} + +// ------------------------------------------------------------ +// Button handler +// ------------------------------------------------------------ +/** @type { ( pSock: Socket, pButton: number, gumpData: GumpData ) => void } */ +function onGumpPress( pSock, pButton, gumpData ) +{ + var pUser = pSock.currentChar; + if( !ValidateObject( pUser )) + return; + + // Close + if( pButton == 1 || pButton == 0 ) + return; + + var treeID = String( pUser.GetTempTag( "DLG_TreeID" )); + var nodeID = String( pUser.GetTempTag( "DLG_NodeID" )); + var npcSer = parseInt( pUser.GetTempTag( "DLG_NpcSerial" ), 10 ); + if( isNaN(npcSer) ) npcSer = 0; + + var pNpc = null; + if( npcSer > 0 ) + pNpc = CalcCharFromSer( npcSer ); + + var tree = TriggerEvent( 0, "GetDialogueTree_Trigger", treeID ); + if( !tree ) + return; + + var node = tree.nodes[nodeID]; + if( !node ) + return; + + var idx = pButton - 100; + if( idx < 0 ) + return; + + var choice = (node.choices && node.choices[idx]) ? node.choices[idx] : null; + if( !choice ) + return; + + if( !ChoiceAllowed( pUser, choice )) + { + pSock.SysMessage( "You cannot choose that." ); + OpenDialogueTree( pUser, pNpc, treeID, nodeID ); + return; + } + + // Perform action + DoChoiceAction( pUser, pNpc, choice.action ); + + // Advance + if( choice.next == null ) + return; + + OpenDialogueTree( pUser, pNpc, treeID, String(choice.next) ); +} + +function DoChoiceAction( pUser, pNpc, action ) +{ + if( !action ) + return; + + // startQuest + if( action.startQuest ) + { + // Use your quest core start logic + TriggerEvent( 5800, "CheckQuest", pUser, action.startQuest, "start" ); + return; + } + + // turnInQuest + if( action.turnInQuest ) + { + TriggerEvent( 5800, "CompleteQuest", pUser, action.turnInQuest ); + return; + } + + // setTags + if( action.setTags ) + { + for( var k in action.setTags ) + { + if( action.setTags.hasOwnProperty(k) ) + { + var v = action.setTags[k]; + pUser.SetTag( k, v ); + } + } + } + + // sysMsg + if( action.sysMsg ) + { + var sock = pUser.socket; + if( sock ) + sock.SysMessage( String(action.sysMsg) ); + } +} \ No newline at end of file diff --git a/data/js/server/quest/quest_dialogue_trees.js.js b/data/js/server/quest/quest_dialogue_trees.js.js new file mode 100644 index 000000000..d1a01d903 --- /dev/null +++ b/data/js/server/quest/quest_dialogue_trees.js.js @@ -0,0 +1,130 @@ +/// +// @ts-check + +// ============================================================================= +// Quest Dialogue Trees +// - nodeId: string +// - text: string (HTML allowed) +// - choices: array of { text, next, action?, cond? } +// +// cond can be: +// - tagEquals: { tag:"SomeTag", value:1 } +// - tagMin: { tag:"SomeTag", value:10 } +// - hasQuest: questID +// - completedQuest: questID +// +// action can be: +// - startQuest: questID +// - turnInQuest: questID +// - setTags: { "TagA": 1, "TagB": null } (null removes) +// - sysMsg: "some text" +// ============================================================================= + +function GetDialogueTree( treeID ) +{ + // Return a single tree by id + var trees = { + "blacksmith_tutorial": { + title: "Garrick the Smith", + startNode: "start", + nodes: { + "start": { + text: + "Garrick: Hmph. If you want coin, you work. " + + "Do you know your way around a forge?", + choices: [ + { text: "I want work.", next: "offer_work" }, + { text: "Tell me about this town.", next: "town" }, + { text: "Goodbye.", next: null } + ] + }, + + "town": { + text: + "Garrick: Cold nights. Hard people. Good steel. " + + "Keep your head down and your blade sharp.", + choices: [ + { text: "Back.", next: "start" }, + { text: "Goodbye.", next: null } + ] + }, + + "offer_work": { + text: + "Garrick: Bring me ten bucklers. Then we talk pay.", + choices: [ + { + text: "I will do it.", + next: "accepted", + action: { startQuest: 5 } // Example: quest id 5 "Battered Bucklers" + }, + { + text: "Not right now.", + next: "start" + } + ] + }, + + "accepted": { + text: + "Garrick: Good. Do not waste my time. " + + "Come back when the work is done.", + choices: [ + { + text: "I have completed it.", + next: "turnin_check", + cond: { hasQuest: 5 } + }, + { + text: "Back.", + next: "start" + } + ] + }, + + "turnin_check": { + text: + "Garrick: Let me see...", + choices: [ + { + text: "Turn in the bucklers.", + next: "turnin_done", + action: { turnInQuest: 5 } + }, + { text: "Back.", next: "accepted" } + ] + }, + + "turnin_done": { + text: + "Garrick: Finally. Here. And listen... you did fine.", + choices: [ + { + text: "Thanks.", + next: "after_turnin", + action: { setTags: { "SmithTutorialDone": 1 } } + }, + { text: "Goodbye.", next: null } + ] + }, + + "after_turnin": { + text: + "Garrick: If you want more, ask again tomorrow.", + choices: [ + { text: "Goodbye.", next: null } + ] + } + } + } + }; + + return trees[treeID] || null; +} + +// Expose via TriggerEvent usage pattern if you want. +// Example: attach script id and call TriggerEvent(SCRIPTID, "GetDialogueTree", treeID) +function GetDialogueTree_Trigger( treeID ) +{ + return GetDialogueTree( treeID ); +} \ No newline at end of file diff --git a/data/js/server/quest/quest_event_ondeathblow.js b/data/js/server/quest/quest_event_ondeathblow.js new file mode 100644 index 000000000..5c5b6f7c2 --- /dev/null +++ b/data/js/server/quest/quest_event_ondeathblow.js @@ -0,0 +1,10 @@ +/// +// @ts-check + +// Handle NPC kills for quests +/** @type { ( mKilled: Character, mKiller: Character ) => boolean } */ +function onKill( player ,victim ) +{ + TriggerEvent( 5800, "CreatureKilled", victim, player ); + return true; +} diff --git a/data/js/server/quest/quest_event_onenterregion.js b/data/js/server/quest/quest_event_onenterregion.js new file mode 100644 index 000000000..f4f78acf2 --- /dev/null +++ b/data/js/server/quest/quest_event_onenterregion.js @@ -0,0 +1,43 @@ +/// +// @ts-check + +function onEnterRegion( pEntering, regionEntered ) +{ + var pSock = pEntering.socket; + if( pSock == null) + { + return; + } + + // Fetch the active quests for the player + var activeQuests = TriggerEvent( 5800, "ReadQuestProgress", pEntering ); + + if( !activeQuests || activeQuests.length == 0 ) + { + return; + } + + // Loop through active quests to check for skill training in the entered region + for( var i = 0; i < activeQuests.length; i++ ) + { + var questEntry = activeQuests[i]; + + // Ensure the quest entry matches the player's serial + if( questEntry.serial != pEntering.serial ) + { + continue; // Skip quests not associated with the current player + } + + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + + if( quest.type == "skillgain" ) + { + if( quest.trainingarea ) + { + // Display the skill training message + pSock.SysMessage(quest.ontrainingarea); + } + return; + } + } +} diff --git a/data/js/server/quest/quest_event_onquesttoggle.js b/data/js/server/quest/quest_event_onquesttoggle.js new file mode 100644 index 000000000..03edee0d4 --- /dev/null +++ b/data/js/server/quest/quest_event_onquesttoggle.js @@ -0,0 +1,63 @@ +/// +// @ts-check + +function onQuestToggle( pUser, iUsed ) +{ + var socket = pUser.socket; + + if( !ValidateObject( pUser )) + { + return false; + } + + if( socket == null ) + { + return false; + } + + socket.CustomTarget( 1, GetDictionaryEntry( 19600, socket.language ));//Target the item you wish to toggle Quest Item status on to cancel + return true; +} + +/** @type { ( tSock: Socket, target: Character | Item | null ) => void } */ +function onCallback1( socket, iTarget ) +{ + if( socket == null ) + { + return; + } + + var pUser = socket.currentChar; + if( !ValidateObject( pUser )) + { + return; + } + + if( !ValidateObject( iTarget ) || !iTarget.isItem ) + { + socket.SysMessage( GetDictionaryEntry( 711, socket.language ));//"That is not a valid item." + return; + } + + var itemOwner = GetPackOwner( iTarget, 0 ); + if( !itemOwner || itemOwner.serial != pUser.serial ) + { + socket.SysMessage( GetDictionaryEntry( 19601, socket.language ));//The item must be in your backpack to toggle its quest status. + return; + } + + // Check if the item is already marked as a quest item + if( !iTarget.GetTag( "QuestItem" )) + { + // Notify collection + TriggerEvent( 5800, "ItemCollected", pUser, iTarget, false ); // Toggle on + } + else + { + // Notify uncollection + TriggerEvent( 5800, "ItemCollected", pUser, iTarget, true ); // Toggle off + } + + //repeat the targetting + socket.CustomTarget( 1, GetDictionaryEntry( 19600, socket.language ));//Target the item you wish to toggle Quest Item status on to cancel +} diff --git a/data/js/server/quest/quest_event_onskillgain.js b/data/js/server/quest/quest_event_onskillgain.js new file mode 100644 index 000000000..55980a6c5 --- /dev/null +++ b/data/js/server/quest/quest_event_onskillgain.js @@ -0,0 +1,8 @@ +/// +// @ts-check + +function onSkillGain( pPlayer, skill, skillGainAmount ) +{ + TriggerEvent( 5800, "AccelerateSkillGain", pPlayer, skill, skillGainAmount ); + return false; +} diff --git a/data/js/server/quest/quest_item.js b/data/js/server/quest/quest_item.js new file mode 100644 index 000000000..acd7df656 --- /dev/null +++ b/data/js/server/quest/quest_item.js @@ -0,0 +1,139 @@ +/// +// @ts-check + +// Script attached to quest items +/** @type { ( equipper: Character, equipping: Item ) => boolean } */ +function onEquipAttempt( pEquipper, iEquipped ) +{ + TriggerEvent( 5800, "EquipAttempt", pEquipper, iEquipped ); +} + +/** @type { ( item: Item, dropper: Character ) => number } */ +function onDrop( iDropped, pDropper ) +{ + var pSocket = pDropper.socket; + if( pSocket == null ) + { + return 0; + } + + // Fetch the serial of the drop target + var temp1 = pSocket.GetByte( 10 ); + var temp2 = pSocket.GetByte( 11 ); + var temp3 = pSocket.GetByte( 12 ); + var temp4 = pSocket.GetByte( 13 ); + + if( temp2 == 0xFF ) + { // do not allow Drop on the ground + pSocket.SysMessage( GetDictionaryEntry( 19631, pSocket.language ));//Quest items cannot be dropped on the ground. + return 0; // Prevent ground drop + } + + return 1; // Default allow for other cases +} + +/** @type { ( item: Item, dropper: Character, dest: Item ) => number } */ +function onDropItemOnItem( iDropped, cDropper, iDroppedOn ) +{ + var pSocket = cDropper.socket; + if( pSocket == null ) + { + return 0; + } + + // Check if the dropped item is marked as a quest item + if( !iDropped.GetTag( "QuestItem" )) + { + pSocket.SysMessage( GetDictionaryEntry( 19632, pSocket.language )); // This item is not marked as a quest item. + return 1; + } + + // Check if the item is being dropped on another quest item + if( iDroppedOn.GetTag( "QuestItem" )) + { + // Allow stacking if both items are pileable and of the same type + if( iDropped.isPileable && iDroppedOn.isPileable && iDropped.sectionID == iDroppedOn.sectionID ) + { + return 1; + } + else + { + pSocket.SysMessage( GetDictionaryEntry( 19633, pSocket.language )); // You can only drop quest items into the top-most level of your backpack while you still need them for your quest. + return 0; + } + } + + if( iDroppedOn.layer != 21 ) + { + pSocket.SysMessage( GetDictionaryEntry( 19633, pSocket.language )); // You can only drop quest items into the top-most level of your backpack while you still need them for your quest. + return 0; + } + + // Find owner of root container iDropped is contained in, if any + var packOwner = GetPackOwner( iDropped, 0 ); + if( packOwner != null && packOwner.serial != cDropper.serial ) + { + pSocket.SysMessage( GetDictionaryEntry( 19633, pSocket.language )); // You can only drop quest items into the top-most level of your backpack while you still need them for your quest. + return 0; + } + + return 1; +} + +/** @type { ( item: Item, pickerUpper: Character, objCont: BaseObject ) => boolean } */ +function onPickup( iPickedUp, pGrabber, containerObj ) +{ + var pSocket = pGrabber.socket; + if( pSocket == null ) + { + return false; + } + + // Check if the item is marked as a quest item + if( !iPickedUp.GetTag( "QuestItem" )) + { + return true; // Allow normal pickup for non-quest items + } + + // Check if the item is pileable ( stackable ) + if( iPickedUp.isPileable ) + { + // Handle stackable quest items + var totalAmount = iPickedUp.amount; // Default to 1 if amount is not set + var pickupAmount = pSocket.GetWord( 5 ); // Assuming this retrieves the amount being picked up + + if( pickupAmount !== totalAmount ) + { + pSocket.SysMessage( GetDictionaryEntry( 19634, pSocket.language )); // Stacks of quest items cannot be unstacked. + return false; // Prevent partial stack pickup + } + } + + return true; // Allow the pickup +} + +/** @type { ( user: Character, iUsing: Item ) => boolean } */ +function onUseChecked( pUser, iUsed ) +{ + var pSocket = pUser.socket; + if( pSocket == null ) + { + return false; + } + + if( iUsed.GetTag( "QuestItem" )) + { + pUser.TextMessage( GetDictionaryEntry( 19635, pSocket.language )); // Quest items cannot be used in this way. + return false; // Prevent usage + } + + return true; // Allow usage for non-quest items +} + +/** @type { ( myObj: BaseObject, pSocket: Socket ) => string } */ +function onTooltip( questitem, pSocket ) +{ + var tooltipText = "[" + GetDictionaryEntry( 19636, pSocket.language ) + "]"; // Quest Item + questitem.SetTempTag( "tooltipSortOrder", 1 ); + return tooltipText; +} diff --git a/data/js/server/quest/quest_lists.js b/data/js/server/quest/quest_lists.js new file mode 100644 index 000000000..dadcc2248 --- /dev/null +++ b/data/js/server/quest/quest_lists.js @@ -0,0 +1,1535 @@ +/** + * ============================================================================= + * QUEST LIST MODULE + * ============================================================================= + * + * File: QuestList.js + * Purpose: + * Centralized registry of all quest definitions for the game. + * Provides a consistent structure for quest data, enabling retrieval + * by ID or iteration over all available quests. + * + * Usage: + * - QuestList() -> Returns an array of all quest definitions. + * - QuestList(questID) -> Returns the quest with the given ID, or null if not found. + * + * ============================================================================= + * QUEST OBJECT STRUCTURE (DL/UOX3) + * ============================================================================= + * + * Core Details: + * ------------- + * title - Display name of the quest. + * description - Narrative/backstory shown in quest conversation/log. + * uncomplete - NPC text when quest is not yet complete. + * complete - NPC text when quest is completed. + * oncomplete - Optional completion message (commonly used by skillgain). + * refuse - NPC text when player declines the quest. + * npcPhrases - Optional NPC idle chatter. + * + * Quest Classification: + * ---------------------- + * type - Quest type: + * collect, timecollect, kill, timekills, multi, delivery, skillgain + * (reward types are defined under "rewards", not in "type") + * category - Quest grouping (e.g., "Side Quests", "Daily", "Main Story"). + * + * Flags & Control: + * ---------------- + * oneTimeQuest - 1/true = quest can only be completed once. + * dailyQuest - 1/true = quest can be repeated after reset window. + * resetDailyTime - Hours until daily quest becomes available again (default 24). + * timeLimit - Timed quest limit in seconds (converted to ms internally). + * questTurnIn - 1 = Requires NPC turn-in, 0 = Auto-complete when objectives met. + * bankgold - (reward-level flag typically) 1 = gold to bank, 0 = to pack. + * requiresQuestID - Prerequisite questID that must be archived before accepting. + * + * Objectives: + * ----------- + * targetItems - Items to collect: + * [{ sectionID, name, amount }] + * targetKills - Creatures to defeat: + * [{ npcID, name, amount }] + * + * Delivery Quests: + * ---------------- + * deliveryItem - Item details: + * { sectionID, name, amount } + * targetDelivery - NPC recipient target: + * { npcID?, sectionID?, name?, location?: { x, y, z, world } } + * + * Skillgain Quests: + * ----------------- + * targetSkill - Skill ID to train (numeric). + * targetRegion - Region ID required for accelerated gain (numeric). + * regionName - Friendly region label for UI. + * maxSkillPoints - Goal in tenths (e.g. 50.0 = 500 tenths if you store in tenths). + * minPoint - Min accelerated gain per proc (tenths). + * maxPoint - Max accelerated gain per proc (tenths). + * + * ============================================================================= + * REWARDS + * ============================================================================= + * + * rewards - Array of reward objects. Supported reward types: + * - { type:"gold", amount, bankgold?: 1|0 } + * - { type:"item", sectionID, amount, name?, color?|hue? } + * - { type:"karma", amount } + * - { type:"fame", amount } + * - { type:"skill", skill:, amount: } // respects skillCaps + * - { type:"skillpoints", amount: } // pooled points + * - { type:"virtue", virtueIndex:<0..7>, amount:, name? } + * + * virtueIndex mapping: + * 0 Humility, 1 Sacrifice, 2 Compassion, 3 Spirituality, + * 4 Valor, 5 Honor, 6 Justice, 7 Honesty + * + * Pooled skill points: + * - Stored under tag "UnspentSkillPoints" in tenths. + * - Spend via: + * SpendSkillPoints( player, socket, "Magery", 3.0 ); // +3.0 Magery (respects caps) + * SpendSkillPoints( player, socket, 27, 2.5 ); // +2.5 skill ID 27 (e.g., Tactics) + * + * ============================================================================= + * QUEST CHAINS (LINEAR + BRANCHING) + * ============================================================================= + * + * Linear chain: + * nextQuestID - Next questID in chain (legacy/simple). + * + * Branching chain: + * nextQuest - Array of candidate next quests resolved in order: + * [ + * { questID: 20011, cond: { completedQuest: 20010 } }, + * { questID: 20012, cond: { tagMin: { tag:"dl_rep", value: 10 } } }, + * { questID: 20013, cond: { always: true } } + * ] + * + * Supported cond keys: + * - tagEquals: { tag:"X", value: 1 } + * - tagMin: { tag:"X", value: 10 } + * - completedQuest: + * - notCompletedQuest: + * - hasQuest: // active + * - notHasQuest: + * - worldStateEquals: { key:"ws_key", value: 1 } + * - worldStateMin: { key:"ws_key", value: 10 } + * - always: true + * + * ============================================================================= + * QUEST COMPLETION EFFECTS (TAGS + WORLD STATE) + * ============================================================================= + * + * Persistent Tag Updates: + * ----------------------- + * setTags - Apply/remove persistent character tags on completion. + * Example: setTags: { "starterTier": 2, "needsTutorial": null } + * + * setTagDeltas - Add/subtract integer deltas to persistent tags. + * Missing/non-numeric treated as 0. + * Example: setTagDeltas: { "FF_GuardStanding": 2, "WinterEventPoints": 5 } + * + * deltaRules - Optional per-tag clamping for setTagDeltas. + * Example: + * deltaRules: { + * "FF_GuardStanding": { min: -100, max: 100 }, + * "WinterEventPoints": { min: 0, max: 999 } + * } + * + * deltaRulesDefault + * - Optional default clamp rule for setTagDeltas keys without an entry. + * Example: deltaRulesDefault: { min: 0, max: 100 } + * + * Temporary Tag Updates: + * ---------------------- + * setTempTags - Apply/remove temp tags on completion. + * setTempTagDeltas - Add/subtract integer deltas to temp tags. + * tempDeltaRules - Optional per-key clamping for setTempTagDeltas. + * tempDeltaRulesDefault + * - Optional default clamp for setTempTagDeltas. + * + * World State (file-backed, per-character-keyed): + * ----------------------------------------------- + * setWorldState - Set/remove integer world-state keys on completion. + * Example: setWorldState: { "dl_ws_joinedthieves": 1, "dl_ws_oldkey": null } + * + * worldStateDeltas - Add/subtract integer deltas to existing world-state keys. + * Example: worldStateDeltas: { "dl_ws_ironreachrep": 5 } + * + * worldStateDeltaRules / worldStateDeltaRulesDefault + * - Optional clamp rules for worldStateDeltas (same min/max schema). + * + * ============================================================================= + * FUNCTION SIGNATURE + * ============================================================================= + * QuestList(questID?) + * questID - Optional quest ID. If omitted, returns all quests. + * Returns - Quest object, array of quests, or null if ID not found. + * + * ============================================================================= + */ + +function QuestList( questID ) +{ + var questList = { + //New Player Quests + "1": { + title: "More Ore Please", + description: "Have a pickaxe? My supplier is late and I need some iron ore so I can complete a bulk order for another " + + "merchant. If you can get me some soon I'll pay you double what it's worth on the market. Just find a cave " + + "or mountainside and try to use your pickaxe there, maybe you'll strike a good vein! 5 large pieces should do it.", + uncomplete: "Hmmm we need some more Ore. Try finding a mountain or cave, and give it a whack.", + complete: "I see you found a good vien! Great! This will help get this order out on time. Good work!", + refuse: "Not feeling strong enough today? Its alright, I didn't need a bucket of rocks anyway.", + npcPhrases: [ + "Bring me iron, and I'll reward you handsomely.", + "Have you collected the ore yet?", + "Don't keep me waiting for those materials!" + ], + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "ore", amount: 5, name: "Iron Ore" } + ], + rewards: [ + { type: "item", sectionID: "minerssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "2": { + title: "A Stitch in Time", + description: "Oh how I wish I had a fancy dress like the noble ladies of Castle British! I don't have much... but I " + + "have a few trinkets I might trade for it. It would mean the world to me to go to a fancy ball and dance " + + "the night away. Oh, and I could tell you how to make one! You just need to use your sewing kit on enough " + + "cut cloth, that's all.", + uncomplete: "Hello again! Do you need anything? You may want to visit the tailor's shop for cloth and a sewing kit, " + + "if you don't already have them.", + complete: "It's gorgeous! I only have a few things to give you in return, but I can't thank you enough! Maybe I'll " + + "even catch Uzeraan's eye at the, er, *blushes* I mean, I can't wait to wear it to the next town dance!", + refuse: "Won't you reconsider? It'd mean the world to me, it would!", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "0x1eff", amount: 1, name: "Fancy Dress" } + ], + rewards: [ + { type: "item", sectionID: "oldring", amount: 1, name: "an old ring" }, + { type: "item", sectionID: "oldnecklace", amount: 1, name: "an old necklace" } + ] + }, + "3": { + title: "Baker's Dozen", + description: "You there! Do you know much about the ways of cooking? If you help me out, I'll show you a thing or two about " + + "how it's done. Bring me some cookie mix, about 5 batches will do it, and I will reward you. Although, I don't " + + "think you can buy it, you can make some in a snap! First get a rolling pin or frying pan or even a flour sifter. " + + "Then you mix one pinch of flour with some water and you've got some dough! Take that dough and add one dollop of " + + "honey and you've got sweet dough. add one more drop of honey and you've got cookie mix.See ? Nothing to it! Now " + + "get to work", + uncomplete: "You're not quite done yet. Get back to work!", + complete: "Thank you! I haven't been this excited about food in months!", + refuse: "Argh, I absolutely must have more of these 'cookies!' Come back if you change your mind.", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "0x103F", amount: 5, name: "Cookie Mix" } + ], + rewards: [ + { type: "item", sectionID: "chefssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "4": { + title: "Chop Chop, On The Double!", + description: "That's right, move it! I need sixty logs on the double, and they need to be freshly cut! If you can get them to " + + "me fast I'll have your payment in your hands before you have the scent of pine out from beneath your nostrils. Just " + + "get a sharp axe and hack away at some of the trees in the land and your lumberjacking skill will rise in no time.", + uncomplete: "You're not quite done yet. Get back to work!", + complete: "Ahhh! The smell of fresh cut lumber. And look at you, all strong and proud, as if you had done an honest days work!", + refuse: "Or perhaps you'd rather not.", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "0x1BE0", amount: 60, name: "Log" } + ], + rewards: [ + { type: "item", sectionID: "lumberjackssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "5": { + title: "Battered Bucklers", + description: "Hey there! Yeah... you! Ya' any good with a hammer? Tell ya what, if yer thinking about tryin' some metal work, " + + "and have a bit of skill, I can show ya how to bend it into shape.Just get some of those ingots there, and grab a " + + "hammer and use it over here at this forge.I need a few more bucklers hammered out to fill this here order with..." + + "hmmm about ten more.that'll give some taste of how to work the metal.", + uncomplete: "Come On! Whats that... a bucket? We need ten bucklers... not spitoons.", + complete: "Thanks for the help. Here's something for ya to remember me by.", + refuse: "Not enough muscle on yer bones to use it? hmph, probably afraid of the sparks markin' up yer loverly skin... to" + + "good for some honest labor...ha!...off with ya!", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "Buckler", amount: 10, name: "Buckler" } + ], + rewards: [ + { type: "item", sectionID: "smithssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "6": { + title: "A clockwork puzzle", + description: "'Tis a riddle, you see! What kind of clock is only right twice per day? A broken one! laughs heartily " + + "Ah, yes *wipes eye*, that's one of my favorites! Ah... to business. Could you fashion me some clock parts? " + + "I wish my own clocks to be right all the day long! You'll need some tinker's tools and some iron ingots, I " + + "think, but from there it should be just a matter of working the metal.", + uncomplete: "You're not quite done yet. Get back to work!", + complete: "Wonderful! Tick tock, tick tock, soon all shall be well with grandfather's clock!", + refuse: "Or perhaps you'd rather not.", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "0x104F", amount: 5, name: "Clock Parts" } + ], + rewards: [ + { type: "item", sectionID: "tinkerssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "7": { + title: "The Delucians Lost Mine", + description: "Find Jacob's Lost Mine and mine iron ore there, using a pickaxe or shovel. Bring it back to Jacob's forge and " + + "smelt the ore into ingots, until you have raised your Mining skill to 50. You may find a packhorse useful for " + + "hauling the ore around.The animal trainer in New Haven has packhorses for sale.< br >
-----

Howdy! " + + "Welcome to my camp. It's not much, I know, but it's all I'll be needin' up here. I don't need them fancy things those " + + "townspeople have down there in New Haven. Nope, not one bit. Just me, Bessie, my pick and a thick vein 'o valorite. " + + "

Anyhows, I'm guessin' that you're up here to ask me about minin', aren't ya? Well, don't be expectin' me to " + + "tell you where the valorite's at, cause I ain't gonna tell the King of Britannia, much less the likes of you. But I " + + "will show ya how to mine and smelt iron, cause there certainly is a 'nough of up in these hills.

*Jacob looks " + + "around, with a perplexed look on his face*

Problem is, I can't remember where my iron mine's at, so you'll " + + "have to find it yourself. Once you're there, have at it with a pickaxe or shovel, then haul it back to camp and I'll " + + "show ya how to smelt it. Ya look a bit wimpy, so you might wanna go buy yourself a packhorse in town from the animal " + + "trainer to help you haul around all that ore.

When you're an Apprentice Miner, talk to me and I'll give ya a " + + "little somethin' I've got layin' around here... somewhere.", + uncomplete: "Where ya been off a gallivantin all day, pilgrim? You aint seen no hard work yet! Get yer arse back out there to " + + "my mine and dig up some more iron.Dont forget to take a pickaxe or shovel, and if youre so inclined, a packhorse too.", + complete: "Dang gun it! If that don't beat all! Ya went and did it, didnt ya? What we got ourselves here is a mighty fine brand " + + "spankin new Apprentice Miner!
< br > I can see ya put some meat on them bones too while you were at it!
< br > Heres " + + "that little somethin I told ya I had for ya.Its a pickaxe with some high falutin magic inside thatll help you find " + + "the good stuff when youre off minin. It wears out fast, though, so you can only use it a few times a day.< br >
Welp," + + "Ive got some smeltin to do, so off with ya.Good luck, pilgrim !", + refuse: "Couldnt find my iron mine, could ya? Well, neither can I!

*Jacob laughs*

Oh, ya dont wanna find it? " + + "Well, allrighty then, ya might as well head on back down to town then and stop cluttering up my camp.Come back and " + + "talk to me if youre interested in learnin bout minin.", + oncomplete: "You have achieved the rank of Apprentice Miner. Return to Jacob Waltz in at his camp in the hills above New Haven as soon as you can to claim your reward.", + trainingarea: "You can almost smell the ore in the rocks here! Your ability to improve your Mining skill is enhanced in this area.", + npcPhrases: [ + "You there! I can use some help mining these rocks!" + ], + type: "skillgain", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetRegion: 194, // Example region ID for sparring grounds + regionName: "Haven Mountains", + targetSkill: 45, // Skill ID 45 + maxSkillPoints: 500, // 50.0 in-game skill points + minPoint: 2, //min skill point 0.2 + maxPoint: 5, //max skill point 0.5 + rewards: [ + { type: "item", sectionID: "jacobspickaxe", amount: 1, name: "Jacob's Pickaxe" } + ] + }, + "8": { + title: "The Inner Warrior", + description: "Head East out of town to Old Haven. Expend stamina and mana until you have raised your Focus skill to 50. Well, hello there. " + + "Don't you like quite the adventureer! You want to learn more about Focus, do you? I can teach you something about that, but " + + "first you should know that not everyone can be a disciplined enough to excel at it. Focus is the ability to achive inner balance " + + "in both body and spirit, so that you recover from physical and mental exertion faster than you other wise would.If you want to " + + "practice Focus, the best place to do that is east of here, in Old Haven, where you'll find an undead infestation, Exert yourself " + + "physically by engaging in combat and moving quickly.For testing your mental balance, expend mana in whatever way you find most " + + "suitable to your abilites.Casting spells and using abilites work well for consuming your mana.Go.Train hard, and you will find " + + "that your concentration will imporve naturally.When you've improved your ability to focus yourself at an Apprentice level, come " + + "back to me and i shall give you something worthy of your new ability.", + uncomplete: "Hell again. I see you've returned, but it seems that your Focus skill hasn't improved as much as it could have. Just head east, " + + "to Old Haven, and exert yourself physically and mentally as much as possible. To do this physically engage in combat and move as " + + "quickly as you can.For exerting yourself mentally, expend mana in whatever way you find most suitable to your abilites.Casting " + + "spells and using abilites work well for consuming your mana.", + complete: "Look Who it is! I knew you could do it if you just had the discipline to apply yourself. It feels good to recover from battle " + + "so quickly, doesn't it? Just wait until you become a Grandmaster, It's amazing! Please take this gift, as you've more than earned " + + "it with your hard work.It will help you recover even faster during battle, and provides a bit of protection aswell.You have so " + + "much more potential, so don't stop trying to improve your Focus now! Safe travels!", + refuse: "I'm disappointed. You have alot of inner potential, and it would pain me greatly to see you waste that. Oh well. If you change your mind ill be right here.", + oncomplete: "You have achieved the rank of Apprentice Miner. Return to Jacob Waltz in at his camp in the hills above New Haven as soon as you can to claim your reward.", + trainingarea: "You feel much more attuned to yourself. Your ability to improve Focus skill is enhanced in this area.", + npcPhrases: [ + "Know yourself, and you will become a true warrior." + ], + type: "skillgain", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, // 0 gold drops in pack if 1 gold goes to bank. + targetRegion: 195, // Example region ID for sparring grounds + regionName: "Old Haven Training", + targetSkill: 50, // Skill ID 50 + maxSkillPoints: 500, // 50.0 in-game skill points + minPoint: 2, //min skill point 0.2 + maxPoint: 5, //max skill point 0.5 + rewards: [ + { type: "item", sectionID: "claspofconcentration", amount: 1, name: "Clasp of Concentration" } + ] + }, + "9": { + "title": "It's Hammer Time!", + "description": "Create new daggers and maces using the forge and anvil in George's shop. Try making daggers up to 45 skill, then switch to making maces until 50 skill. Hail, and welcome to my humble shop. I am George Hephaestus, New Haven's blacksmith. I assume that you're here to ask me to train you to be an Apprentice Blacksmith. I certainly can do that, but you're going to have to supply your own ingots. You can always buy them at the market, but I highly suggest that you mine your own. That way, any items you sell will be pure profit! So, once you have a supply of ingots, use my forge and anvil to create items. You'll also need a supply of the proper tools, you can use a smith's hammer, a sledgehammer, or tongs. You can either make them yourself if you have the tinkering skill, or buy them from a tinker at the market. Since I'll be around to give you advice, you'll learn faster here than anywhere else. Start off making daggers until you reach 45 blacksmithing skill, then switch to maces until you've achieved 50. Once you've done that, come talk to me and I'll give you something for your hard work.", + "uncomplete": "You're doing well, but you're not quite there yet. Remember that the quickest way to learn is to make daggers up to 45 skill, and then switch to maces. Also, don't forget that using my forge and anvil will help you learn faster.", + "complete": "I've been watching you get better and better as you've been smithing, and I have to say, you're a natural! It's a long road to being a Grandmaster Blacksmith, but I have no doubt that if you put your mind to it you'll get there someday. Let me give you one final piece of advice. If you're smithing just to practice and improve your skill, make items that are moderately difficult (60%-80% success chance), and try to stick to ones that use fewer ingots. Now that you're an Apprentice Blacksmith, I have something for you. While you were busy practicing, I was crafting this hammer for you. It's finely balanced and has a bit of magic imbued within that will help you craft better items. However, that magic needs to restore itself over time, so you can only use it so many times per day. I hope you find it useful.", + "refuse": "You're not interested in learning to be a smith, eh? I thought for sure that's why you were here. Oh well, if you change your mind, you can always come back and talk to me.", + "oncomplete": "You have achieved the rank of Apprentice Blacksmith. Return to George Hephaestus in New Haven to claim your reward.", + "trainingarea": "You feel much more attuned to blacksmithing. Your ability to improve blacksmithing skill is enhanced in this area.", + "npcPhrases": [ + "A steady hand creates a perfect blade." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 195, + "regionName": "New Haven Blacksmith Shop", + "targetSkill": 45, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "apprenticeblacksmithhammer", "amount": 1, "name": "Apprentice Blacksmith Hammer" } + ] + }, + "10": { + "title": "The Right Tool for the Job", + "description": "Create new scissors and hammers while inside Amelia's workshop. Try making scissors up to 45 skill, then switch to making hammers until 50 skill. Hello! I guess you're here to learn something about Tinkering, eh? You've come to the right place, as Tinkering is what I've dedicated my life to. You'll need two things to get started: a supply of ingots and the right tools for the job. You can either buy ingots from the market or go mine them yourself. As for tools, you can try making your own set of Tinker's Tools, or if you'd prefer to buy them, I have some for sale. Working here in my shop will let me give you pointers as you go, so you'll be able to learn faster than anywhere else. Start off making scissors until you reach 45 tinkering skill, then switch to hammers until you've achieved 50. Once you've done that, come talk to me and I'll give you something for your hard work.", + "uncomplete": "Nice going! You're not quite at Apprentice Tinkering yet, though, so you better get back to work. Remember that the quickest way to learn is to make scissors up until 45 skill, and then switch to hammers. Also, don't forget that working here in my shop will let me give you tips so you can learn faster.", + "complete": "You've done it! Look at our brand-new Apprentice Tinker! You've still got quite a lot to learn if you want to be a Grandmaster Tinker, but I believe you can do it! Just keep in mind that if you're tinkering just to practice and improve your skill, make items that are moderately difficult (60-80% success chance), and try to stick to ones that use fewer ingots. Come here, my brand-new Apprentice Tinker, I want to give you something special. I created this just for you, so I hope you like it. It's a set of Tinker's Tools that contains a bit of magic. These tools have more charges than any Tinker's Tools a Tinker can make. You can even use them to make a normal set of tools, so that way you won't ever find yourself stuck somewhere with no tools!", + "refuse": "Im disappointed that you arent interested in learning more about Tinkering. Its really such a useful skill! *Amelia smiles* At least you know where to find me if you change your mind, since I rarely spend time outside of this shop.", + "oncomplete": "You have achieved the rank of Apprentice Tinker. Return to Amelia at her workshop to claim your reward.", + "trainingarea": "By tinkering inside of Amelias workshop, she is able to give you advice. This helps you hone your Tinkering skill faster than normal.", + "npcPhrases": [ + "Every tool has a purpose; find yours.", + "Tinkering is very useful for a blacksmith.You can make your own tools." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 196, + "regionName": "Springs And Things Workshop", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "ameliastoolbox", "amount": 1, "name": "Amelia's Toolbox" } + ] + }, + "11": { + "title": "Knowing Thine Enemy", + "description": "Head East out of town to Old Haven. Battle monsters there, or heal yourself and other players, until you have raised your Anatomy skill to 50. Hail and well met. You must be here to improve your knowledge of Anatomy. Well, you've come to the right place because I can teach you what you need to know. At least all you'll need to know for now. Haha! Knowing about how living things work inside can be a very useful skill. Not only can you learn where to strike an opponent to hurt him the most, but you can use what you learn to heal wounds better as well. Just walking around town, you can even tell if someone is strong or weak or if they happen to be particularly dexterous or not. If you're interested in learning more, I'd advise you to head out to Old Haven, just to the east, and jump into the fray. You'll learn best by engaging in combat while keeping you and your fellow adventurers healed, or you can even try sizing up your opponents. While you're gone, I'll dig up something you may find useful.", + "uncomplete": "I'm surprised to see you back so soon. You've still got a ways to go if you want to really understand the science of Anatomy. Head out to Old Haven and practice combat and healing yourself or other adventurers.", + "complete": "By the Virtues, you've done it! Congratulations mate! You still have quite a ways to go if you want to perfect your knowledge of Anatomy, but I know you'll get there someday. Just keep at it. In the meantime, here's a piece of armor that you might find useful. It's not fancy, but it'll serve you well if you choose to wear it. Happy adventuring, and remember to keep your cranium separate from your clavicle!", + "refuse": "It's your choice, but I wouldn't head out there without knowing what makes those things tick inside! If you change your mind, you can find me right here dissecting frogs, cats or even the occasional unlucky adventurer.", + "oncomplete": "You have achieved the rank of Apprentice Anatomist. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel very willing to learn more about the body. Your ability to hone your Anatomy skill is enhanced in this area.", + "npcPhrases": [ + "Know thy enemy, and victory shall be yours.", + "Learning of the body will allow you to excel in combat." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 197, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "tunicoffguarding", "amount": 1, "name": "Tunic of Guarding" } + ] + }, + "12": { + "title": "Bruises, Bandages, and Blood", + "description": "Head East out of town and go to Old Haven. Heal yourself and other players until you have raised your Healing skill to 50. Ah, welcome to my humble practice. I am Avicenna, New Haven's resident Healer. A lot of adventurers head out into the wild from here, so I keep rather busy when they come back bruised, bleeding, or worse. I can teach you how to bandage a wound, sure, but it's not a job for the queasy! For some folks, the mere sight of blood is too much for them, but it's something you'll get used to over time. It is one thing to cut open a living thing, but it's quite another to sew it back up and save it from sure death. 'Tis noble work, healing. Best way for you to practice fixing up wounds is to head east out to Old Haven and either practice binding up your own wounds, or practice on someone else. Surely they'll be grateful for the assistance. Make sure to take enough bandages with you! You don't want to run out in the middle of a tough fight.", + "uncomplete": "Hail! 'Tis good to see you again. Unfortunately, you're not quite ready to call yourself an Apprentice Healer quite yet. Head back out to Old Haven, due east from here, and bandage up some wounds. Yours or someone else's, it doesn't much matter.", + "complete": "Hello there, friend. I see you've returned in one piece, and you're an Apprentice Healer to boot! You should be proud of your accomplishment, as not everyone has 'the touch' when it comes to healing. I can't stand to see such good work go unrewarded, so I have something I'd like you to have. It's not much, but it'll help you heal just a little faster, and maybe keep you alive. Good luck out there, friend, and don't forget to help your fellow adventurer whenever possible!", + "refuse": "No? Are you sure? Well, when you feel that you're ready to practice your healing, come back to me. I'll be right here, fixing up adventurers and curing the occasional cold!", + "oncomplete": "You have achieved the rank of Apprentice Healer. Return to Avicenna in New Haven to claim your reward.", + "trainingarea": "You feel fresh and are eager to heal wounds. Your ability to improve your Healing skill is enhanced in this area.", + "npcPhrases": [ + "A healer's touch saves lives, even your own.", + " A warrior needs to learn how to apply bandages to wounds." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 198, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "healerstouch", "amount": 1, "name": "Healer's Touch" } + ] + }, + "13": { + "title": "Becoming One With The Shadows", + "description": "Practice hiding in the Ninja Dojo until you reach 50 Hiding skill. Come closer. Don't be afraid. The shadows will not harm you. To be a successful Ninja, you must learn to become one with the shadows. The Ninja Dojo is the ideal place to learn the art of concealment. Practice hiding here. Talk to me once you have achieved the rank of Apprentice Rogue (for Hiding), and I shall reward you.", + "uncomplete": "You have not achieved the rank of Apprentice Rogue (for Hiding). Talk to me when you feel you have accomplished this.", + "complete": "Not bad at all. You have learned to control your fear of the dark and you are becoming one with the shadows. If you haven't already talked to Jun, I advise you do so. Jun can teach you how to stealth undetected. Hiding and Stealth are essential skills to master when becoming a Ninja. As promised, I have a reward for you. Here are some smokebombs. As long as you are an Apprentice Ninja and have mana available, you will be able to use them. They will allow you to hide while in the middle of combat. I hope these serve you well.", + "refuse": "If you wish to become one with the shadows, come back and talk to me.", + "oncomplete": "You have achieved the rank of Apprentice Rogue. Return to your instructor at the Ninja Dojo to claim your reward.", + "trainingarea": "You feel you can easily slip into the shadows here. Your ability to hide is enhanced in this area.", + "npcPhrases": [ + "Fear not the shadows; become one with them.", + "The shadows are your allies, not your enemies.", + "To be undetected means you cannot be harmed." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 199, + "regionName": "Haven Dojo", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "bagofsmokebombs", "amount": 1, "name": "Bag of Smoke Bombs" } + ] + }, + "14": { + "title": "Walking Silently", + "description": "Head East out of town and go to Old Haven. While wearing normal clothes, practice Stealth there until you reach 50 Stealth skill. You there. You're not very quiet in your movements. I can help you with that. Not only must you learn to become one with the shadows, but also you must learn to quiet your movements. Old Haven is the ideal place to learn how to Stealth. Head East out of town and go to Old Haven. While wearing normal clothes, practice Stealth there. Stealth becomes more difficult as you wear heavier pieces of armor, so for now, only wear clothes while practicing Stealth. You can only Stealth once you are hidden. If you become visible, use your Hiding skill, and begin slowly walking. Come back to me once you have achieved the rank of Apprentice Rogue (for Stealth), and I will reward you with something useful.", + "uncomplete": "You have not achieved the rank of Apprentice Rogue (for Stealth). Talk to me when you feel you have accomplished this.", + "complete": "Good. You have learned to quiet your movements. If you haven't already talked to Chiyo, I advise you do so. Chiyo can teach you how to become one with the shadows. Hiding and Stealth are essential skills to master when becoming a Ninja. Here is your reward. This leather Ninja jacket is called 'Twilight Jacket.' It will offer greater protection to you. I hope this will serve you well.", + "refuse": "If you want to learn to quiet your movements, talk to me and I will help you.", + "oncomplete": "You have achieved the rank of Apprentice Rogue. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel you can easily slip into the shadows and walk silently here. Your ability to Stealth is enhanced in this area.", + "npcPhrases": [ + "Quiet steps lead to survival in the shadows.", + "Walk Silently. Remain unseen. I can teach you." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 200, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "twilightjacket", "amount": 1, "name": "Twilight Jacket" } + ] + }, + "15": { + "title": "The Art of Stealth", + "description": "Head East out of town and go to Old Haven. While wielding your fencing weapon, battle monsters with focus attack and summon mirror images up to 40 Ninjitsu skill, and continue practicing focus attack on monsters until 50 Ninjitsu skill. Welcome, young one. You seek to learn Ninjitsu. With it, and the book of Ninjitsu, a Ninja can invoke a number of special abilities including transforming into a variety of creatures that give unique bonuses, using stealth to attack unsuspecting opponents, or just plain disappearing into thin air! If you do not have a book of Ninjitsu, you can purchase one from me. I have an assignment for you. Head East out of town and go to Old Haven. While wielding your fencing weapon, battle monsters with focus attack and summon mirror images up to the Novice rank, and continue focusing your attack for greater damage on monsters until you become an Apprentice Ninja. Each image will absorb one attack. The art of deception is a strong defense. Use it wisely. Come back to me once you have achieved the rank of Apprentice Ninja, and I shall reward you with something useful.", + "uncomplete": "You have not achieved the rank of Apprentice Ninja. Come back to me once you have done so.", + "complete": "You have done well, young one. Please accept this kryss as a gift. It is called the 'Silver Serpent Blade.' With it, you will strike with precision and power. This should aid you in your journey as a Ninja. Farewell.", + "refuse": "Come back to me if you want to learn Ninjitsu in the future.", + "oncomplete": "You have achieved the rank of Apprentice Ninja. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel a greater sense of awareness here. Your ability to hone your Ninjitsu skill is enhanced in this area.", + "npcPhrases": [ + "Master the art of deception, for it is a Ninja's greatest weapon.", + "I can teach you Ninjitsu. The Art of Stealth." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 201, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "silverserpentblade", "amount": 1, "name": "Silver Serpent Blade" } + ] + }, + "16": { + "title": "Eyes of a Ranger", + "description": "Track animals, monsters, and people on Haven Island until you have raised your Tracking skill to 50. Hello, Friend. I am Walker, Grandmaster Ranger. An adventurer needs to keep alive in the wilderness. Being able to track those around you is essential to surviving in dangerous places. Certain Ninja abilities are more potent when the Ninja possesses Tracking knowledge. If you want to be a Ninja, or if you simply want to get a leg up on the creatures that inhabit these parts, I advise you to learn how to track them. You can track any animals, monsters, or people on Haven Island. Clear your mind, focus, and note any tracks in the ground or sounds in the air that can help you find your mark. You can do it, friend. I have faith in you. Come back to me once you have achieved the rank of Apprentice Ranger (for Tracking), and I will give you something that may help in your travels. Take care, friend.", + "uncomplete": "So far so good, kid. You are still alive, and you are getting the hang of Tracking. There are many more animals, monsters, and people to track. Come back to me once you have tracked them.", + "complete": "I knew you could do it! You have become a fine Ranger. Just keep practicing, and one day you will become a Grandmaster Ranger, just like me. I have a little something for you that will hopefully aid you in your journeys. These leggings offer some resistances that will hopefully protect you from harm. I hope these serve you well. Farewell, friend.", + "refuse": "Farewell, friend. Be careful out here. If you change your mind and want to learn Tracking, come back and talk to me.", + "oncomplete": "You have achieved the rank of Apprentice Ranger. Return to Walker on Haven Island to claim your reward.", + "trainingarea": "You feel you can track creatures here with ease. Your Tracking skill is enhanced in this area.", + "npcPhrases": [ + "The wilderness holds secrets for those who can track them.", + "There is no theory of evolution. Just a list of creatures I allow to live.", + "I don't sleep. I wait.", + "I can lead a horse to water and make it drink." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 202, + "regionName": "Haven Island", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "walkersleggings", "amount": 1, "name": "Walker's Leggings" } + ] + }, + "17": { + "title": "The Way of the Samurai", + "description": "Head East out of town and go to Old Haven. Use the Confidence defensive stance and attempt to honorably execute monsters there until you have raised your Bushido skill to 50. Greetings. I see you wish to learn the Way of the Samurai. Wielding a blade is easy. Anyone can grasp a sword's hilt. Learning how to fight properly and skillfully is to become an Armsman. Learning how to master weapons, and even more importantly when not to use them, this is the Way of the Warrior. The Way of the Samurai. The Code of Bushido. That is why you are here. Adventure East to Old Haven. Use the Confidence defensive stance and attempt to honorably execute the undead that inhabit there. You will need a book of Bushido. If you do not possess a book of Bushido, you can purchase one from me. If you fail to honorably execute the undead, your defenses will be greatly weakened. Resistances will suffer and Resisting Spells will suffer. A successful parry instantly ends the weakness. If you succeed, however, you will be infused with strength and healing. Your swing speed will also be boosted for a short duration. With practice, you will learn how to master your Bushido abilities. Return to me once you feel that you have become an Apprentice Samurai.", + "uncomplete": "You have not yet proven yourself ready to become an Apprentice Samurai. There are still many more undead to lay to rest. Return to me once you have done so.", + "complete": "You have proven yourself, young one. You will continue to improve as your skills are honed with age. You are an honorable warrior, worthy of the rank Apprentice Samurai. Please accept this no-dachi as a gift. It is called 'The Dragon's Tail.' Upon a successful strike in combat, there is a chance this mighty weapon will replenish your stamina equal to the damage of your attack. I hope 'The Dragon's Tail' serves you well. You have earned it. Farewell for now.", + "refuse": "Good journey to you. Return to me if you wish to live the life of a Samurai.", + "oncomplete": "You have achieved the rank of Apprentice Samurai. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Bushido potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "An honorable warrior lives by the Code of Bushido.", + "Seek me to learn the way of the samurai." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 203, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "thedragonstail", "amount": 1, "name": "The Dragon's Tail" } + ] + }, + "18": { + "title": "Defying the Arcane", + "description": "Head East out of town and go to Old Haven. Battle spell-casting monsters there until you have raised your Resisting Spells skill to 50. Hail and well met! To become a true master of the arcane art of Magery, I suggest learning the complementary skill known as Resisting Spells. While the name of this skill may suggest that it helps with resisting all spells, this is not the case. This skill helps you lessen the severity of spells that lower your stats or ones that last for a specific duration of time. It does not lessen damage from spells such as Energy Bolt or Flamestrike. The Magery spells that can be resisted are Clumsy, Curse, Feeblemind, Mana Drain, Mana Vampire, Paralyze, Paralyze Field, Poison, Poison Field, and Weaken. The Necromancy spells that can be resisted are Blood Oath, Corpse Skin, Mind Rot, and Pain Spike. At higher ranks, the Resisting Spells skill also benefits you by adding a bonus to your minimum elemental resists. This bonus is only applied after all other resist modifications - such as from equipment - have been calculated. It's also not cumulative. It compares the number of your minimum resists to the calculated value of your modifications and uses the higher of the two values. As you can see, Resisting Spells is a difficult skill to understand, and even more difficult to master. This is because in order to improve it, you will have to put yourself in harm's way - as in the path of one of the above spells. Undead have plagued the town of Old Haven. We need your assistance in cleansing the town of this evil influence. Old Haven is located east of here. Battle the undead spell casters that inhabit there. Come back to me once you feel that you are worthy of the rank of Apprentice Mage and I will reward you with an arcane prize.", + "uncomplete": "You have not achieved the rank of Apprentice Mage. Come back to me once you feel that you are worthy of the rank of Apprentice Mage and I will reward you with an arcane prize.", + "complete": "You have successfully begun your journey in becoming a true master of Magery. On behalf of the New Haven Mage Council, I wish to present you with this bracelet. When worn, the Bracelet of Resilience will enhance your resistances vs. the elements, physical, and poison harm. The Bracelet of Resilience also magically enhances your ability to fend off ranged and melee attacks. I hope it serves you well.", + "refuse": "The ability to resist powerful spells is a taxing experience. I understand your resistance in wanting to pursue it. If you wish to reconsider, feel free to return to me for Resisting Spells training. Good journey to you!", + "oncomplete": "You have achieved the rank of Apprentice Mage. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Resisting Spells potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "Resist the arcane, and master your defenses.", + "A mage should learn how to resist spells." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 204, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "braceletofresilience", "amount": 1, "name": "Bracelet of Resilience" } + ] + }, + "19": { + "title": "Stopping the World", + "description": "Head East out of town and go to Old Haven. Use spells and abilities to deplete your mana and meditate there until you have raised your Meditation skill to 50. Well met! I can teach you how to 'Stop the World' around you and focus your inner energies on replenishing your mana. What is mana? Mana is the life force for everyone who practices arcane arts. When a practitioner of magic invokes a spell or scribes a scroll, it consumes mana. Having an abundant supply of mana is vital to excelling as a practitioner of the arcane. Those of us who study the art of Meditation are also known as stoics. The Meditation skill allows stoics to increase the rate at which they regenerate mana. A Stoic needs to perform abilities or cast spells to deplete mana before meditating to replenish it. Meditation can occur passively or actively. Active Meditation is more difficult to master but allows for the stoic to replenish mana at a significantly faster rate. Metal armor interferes with the regenerative properties of Meditation. It is wise to wear leather or cloth protection when meditating. Head east out of town and go to Old Haven. Use spells and abilities to deplete your mana and actively meditate to replenish it. Come back once you feel you are at the worthy rank of Apprentice Stoic and I will reward you with an arcane prize.", + "uncomplete": "You have not achieved the rank of Apprentice Stoic. Come back to me once you feel that you are worthy of the rank Apprentice Stoic and I will reward you with an arcane prize.", + "complete": "Splendid! On behalf of the New Haven Mage Council, I wish to present you with this hat. When worn, the Philosopher's Hat will protect you somewhat from physical attacks. The Philosopher's Hat also enhances the potency of your offensive spells, lowers the mana cost of your arcane spells and abilities, and passively increases your mana regeneration rate. Ah yes, almost forgot. The Philosopher's Hat also grants one other special ability to its wearer. It allows a chance for the wearer to cast spells without using any reagents. I hope the Philosopher's Hat serves you well.", + "refuse": "Seek me out if you ever wish to study the art of Meditation. Good journey.", + "oncomplete": "You have achieved the rank of Apprentice Stoic. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Meditation potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "Quiet the mind and the world will follow.", + "Meditation allows a mage to replenish mana quickly. I can teach you." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 205, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "philosophershat", "amount": 1, "name": "Philosopher's Hat" } + ] + }, + "20": { + "title": "Scribing Arcane Knowledge", + "description": "While here at the New Haven Magery Library, use a scribe's pen and scribe 3rd and 4th circle Magery scrolls that you have in your spellbook. Remember, you will need blank scrolls as well. Do this until you have raised your Inscription skill to 50. Greetings and welcome to the New Haven Magery Library! You wish to learn how to scribe spell scrolls? You have come to the right place! Inscribed with a steady hand and imbued with the power of reagents, a scroll can mean the difference between life and death in a perilous situation. Those knowledgeable in Inscription can transcribe spells to create useful and valuable magical scrolls. Before you can inscribe a spell, you must first be able to cast the spell without the aid of a scroll. This means that you need the appropriate level of proficiency as a mage, the required mana, and the required reagents. Second, you will need a blank scroll to write on and a scribe's pen. Then, you will need to decide which particular spell you wish to scribe. As with the development of all skills, you need to practice Inscription of lower-level spells before you can move onto the more difficult ones. Inscribing a scroll with a magic spell drains your mana. I suggest you begin scribing any 3rd and 4th circle spells that you know. If you don't possess any, you can always barter with one of the local mage merchants or a fellow adventurer that is a seasoned Scribe. Come back to me once you feel that you are worthy of the rank of Apprentice Scribe and I will reward you with an arcane prize.", + "uncomplete": "You have not achieved the rank of Apprentice Scribe. Come back to me once you feel that you are worthy of the rank of Apprentice Scribe and I will reward you with an arcane prize.", + "complete": "Scribing is a very fulfilling pursuit. I am pleased to see you embark on this journey. You sling a pen well! On behalf of the New Haven Mage Council, I wish to present you with this spellbook. When equipped, the Hallowed Spellbook greatly enhances the potency of your offensive spells when used against Undead. Be mindful, though. While this book is equipped, your spells and abilities used against Humanoids, such as other humans, orcs, ettins, and trolls, will diminish in effectiveness. I suggest unequipping the Hallowed Spellbook when battling Humanoids. I hope this spellbook serves you well.", + "refuse": "I understand. When you are ready, feel free to return to me for Inscription training. Thanks for stopping by!", + "oncomplete": "You have achieved the rank of Apprentice Scribe. Return to your instructor in New Haven Magery Library to claim your reward.", + "trainingarea": "Your Inscription potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "A steady hand and a focused mind create wonders.", + "I can teach you how to scribe magic scrolls." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 206, + "regionName": "Haven Library", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "hallowedspellbook", "amount": 1, "name": "Hallowed Spellbook" } + ] + }, + "21": { + "title": "The Mage's Apprentice", + "description": "Head East out of town and go to Old Haven. Cast Fireball and Lightning Bolt against monsters there until you have raised your Magery skill to 50. Greetings. You seek to unlock the secrets of the arcane art of Magery. The New Haven Mage Council has an assignment for you. Undead have plagued the town of Old Haven. We need your assistance in cleansing the town of this evil influence. Old Haven is located east of here. I suggest using your offensive Magery spells such as Fireball and Lightning Bolt against the Undead that inhabit there. Make sure you have plenty of reagents before embarking on your journey. Reagents are required to cast Magery spells. You can purchase extra reagents at the nearby Reagent shop, or you can find reagents growing in the nearby wooded areas. You can see which reagents are required for each spell by looking in your spellbook. Come back to me once you feel that you are worthy of the rank of Apprentice Mage and I will reward you with an arcane prize.", + "uncomplete": "You have not achieved the rank of Apprentice Mage. Come back to me once you feel that you are worthy of the rank of Apprentice Mage and I will reward you with an arcane prize.", + "complete": "Well done! On behalf of the New Haven Mage Council, I wish to present you with this staff. Normally a mage must unequip weapons before spell casting. While wielding your new Ember Staff, however, you will be able to invoke your Magery spells. Even if you do not currently possess skill in Mace Fighting, the Ember Staff will allow you to fight as if you do. However, your Magery skill will be temporarily reduced while doing so. Finally, the Ember Staff occasionally smites a foe with a Fireball while wielding it in melee combat. I hope the Ember Staff serves you well.", + "refuse": "Very well, come back to me when you are ready to practice Magery. You have so much arcane potential. 'Tis a shame to see it go to waste. The New Haven Mage Council could really use your help.", + "oncomplete": "You have achieved the rank of Apprentice Mage. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Magery potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "Harness the arcane, and wield it with precision.", + "Want to unlock the secrets of magery ?" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 207, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "emberstaff", "amount": 1, "name": "Ember Staff" } + ] + }, + "22": { + "title": "A Scholarly Task", + "description": "Head East out of town and go to Old Haven. Use Evaluating Intelligence on all creatures you see there. You can also cast Magery spells as well to raise Evaluating Intelligence. Do these activities until you have raised your Evaluating Intelligence skill to 50. Hello. Truly knowing your opponent is essential for landing your offensive spells with precision. I can teach you how to enhance the effectiveness of your offensive spells, but first you must learn how to size up your opponents intellectually. I have a scholarly task for you. Head East out of town and go to Old Haven. Use Evaluating Intelligence on all creatures you see there. You can also cast Magery spells as well to raise Evaluating Intelligence. Come back to me once you feel that you are worthy of the rank Apprentice Scholar, and I will reward you with an arcane prize.", + "uncomplete": "You have not achieved the rank of Apprentice Scholar. Come back to me once you feel that you are worthy of the rank Apprentice Scholar, and I will reward you with an arcane prize.", + "complete": "You have completed the task. Well done. On behalf of the New Haven Mage Council, I wish to present you with this ring. When worn, the Ring of the Savant enhances your intellectual aptitude and increases your mana pool. Your spell casting abilities will take less time to invoke, and recovering from such spell casting will be hastened. I hope the Ring of the Savant serves you well.", + "refuse": "Return to me if you reconsider and wish to become an Apprentice Scholar.", + "oncomplete": "You have achieved the rank of Apprentice Scholar. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Evaluating Intelligence potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "Understanding your opponent is the key to victory.", + "Want to maximize your spell damage ? I have a scholarly task for you!" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 208, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "ringofthesavant", "amount": 1, "name": "Ring of the Savant" } + ] + }, + "23": { + "title": "The Art of War", + "description": "Head East out of town to Old Haven. Battle monsters there until you have raised your Tactics skill to 50. Knowing how to hold a weapon is only half of the battle. The other half is knowing how to use it against an opponent. It's one thing to kill a few bunnies now and then for fun, but a true warrior knows that the right moves to use against a lich will pretty much get your arse fried by a dragon. I'll help teach you how to fight so that when you do come up against that dragon, maybe you won't have to walk out of there 'OooOOooOOOooOO'ing' and looking for a healer. There are some undead that need cleaning out in Old Haven towards the east. Why don't you head on over there and practice killing things? When you feel like you've got the basics down, come back to me and I'll see if I can scrounge up an item to help you in your adventures later on.", + "uncomplete": "You're making some progress, that I can tell, but you're not quite good enough to last for very long out there by yourself. Head back to Old Haven, to the east, and kill some more undead.", + "complete": "Hey, good job killing those undead! Hopefully someone will come along and clean up the mess. All that blood and guts tends to stink after a few days, and when the wind blows in from the east, it can raise a mighty stink! Since you performed valiantly, please take these arms and use them well. I've seen a few too many harvests to be running around out there myself, so you might as well take it. There is a lot left for you to learn, but I think you'll do fine. Remember to keep your elbows in and stick 'em where it hurts the most!", + "refuse": "That's too bad. I really thought you had it in you. Well, I'm sure those undead will still be there later, so if you change your mind, feel free to stop on by and I'll help you the best I can.", + "oncomplete": "You have achieved the rank of Apprentice Warrior. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel like practicing combat here would really help you learn to fight better. Your ability to raise your Tactics skill is enhanced in this area.", + "npcPhrases": [ + "Master the art of battle, and the field shall be yours.", + "There is an art to slaying your enemies swiftly. It's called tactics, and I can teach it to you." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 209, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "armsofarmstrong", "amount": 1, "name": "Arms of Armstrong" } + ] + }, + "24": { + "title": "Crushing Bones and Taking Names", + "description": "Head East out of town and go to Old Haven. While wielding your mace, battle monsters there until you have raised your Mace Fighting skill to 50. I see you want to learn a real weapon skill and not that toothpick training Jockles has to offer. Real warriors are called Armsmen, and they wield mace weapons. No doubt about it. Nothing is more satisfying than knocking the wind out of your enemies, smashing their armor, crushing their bones, and taking their names. Want to learn how to wield a mace? Well, I have an assignment for you. Head East out of town and go to Old Haven. Undead have plagued the town, so there are plenty of bones for you to smash there. Come back to me after you have achieved the rank of Apprentice Armsman, and I will reward you with a real weapon.", + "uncomplete": "Listen, kid. There are a lot of undead in Old Haven, and you haven't smashed enough of them yet. So get back there and do some more cleansing.", + "complete": "Now that's what I'm talking about! Well done! Don't you like crushing bones and taking names? As I promised, here is a war mace for you. It hits hard. It swings fast. It hits often. What more do you need? Now get out of here and crush some more enemies!", + "refuse": "I thought you wanted to be an Armsman and really make something of yourself. You have potential, kid, but if you want to play with toothpicks, run to Jockles and he will teach you how to clean your teeth with a sword. If you change your mind, come back to me, and I will show you how to wield a real weapon.", + "oncomplete": "You have achieved the rank of Apprentice Armsman. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel much more attuned to your mace. Your ability to hone your Mace Fighting skill is enhanced in this area.", + "npcPhrases": [ + "Nothing beats the power of a good mace in battle.", + "Don't listen to Jockles. Real warriors wield mace weapons!" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 210, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "churchillswarmace", "amount": 1, "name": "Churchill's War Mace" } + ] + }, + "25": { + "title": "The Way of the Blade", + "description": "Head East out of town and go to Old Haven. While wielding your sword, battle monsters there until you have raised your Swordsmanship skill to 50. As you approach, you notice Jockles sizing you up with a skeptical look on his face. 'I can see you want to learn how to handle a blade. It's a lot harder than it looks, and you're going to have to put a lot of time and effort if you ever want to be half as good as I am. I'll tell you what, kid, I'll help you get started, but you're going to have to do all the work if you want to learn something. East of here, outside of town, is Old Haven. It's been overrun with the nastiest of undead you've seen, which makes it a perfect place for you to turn that sloppy grin on your face into actual skill at handling a sword. Make sure you have a sturdy Swordsmanship weapon in good repair before you leave. 'Tis no fun to travel all the way down there just to find out you forgot your blade! When you feel that you've cut down enough of those foul-smelling things to learn how to handle a blade without hurting yourself, come back to me. If I think you've improved enough, I'll give you something suited for a real warrior.'", + "uncomplete": "Jockles looks you up and down. 'Come on! You've got to work harder than that to get better. Now get out of here, go kill some more of those undead to the east in Old Haven, and don't come back till you've got real skill.'", + "complete": "Well, well, look at what we have here! You managed to do it after all. I have to say, I'm a little surprised that you came back in one piece, but since you did, I've got a little something for you. This is a fine blade that served me well in my younger days. Of course, I've got much better swords at my disposal now, so I'll let you go ahead and use it under one condition: take good care of it and treat it with the respect that a fine sword deserves. You're one of the quickest learners I've seen, but you still have a long way to go. Keep at it, and you'll get there someday. Happy hunting, kid.", + "refuse": "Ha! I had a feeling you were a lily-livered pansy. You might have potential, but if you're scared by a few smelly undead, maybe it's better that you stay away from sharp objects. After all, you wouldn't want to hurt yourself swinging a sword. If you change your mind, I might give you another chance...maybe.", + "oncomplete": "You have achieved the rank of Apprentice Swordsman. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel much more attuned to your blade. Your ability to hone your Swordsmanship skill is enhanced in this area.", + "npcPhrases": [ + "A true warrior wields their blade with skill and respect.", + "Talk to me to learn the way of the blade." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 211, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "jocklesquicksword", "amount": 1, "name": "Jockles' Quick Sword" } + ] + }, + "26": { + "title": "En Guarde!", + "description": "Head East out of town and go to Old Haven. Battle monsters there until you have raised your Fencing skill to 50. Well hello there, lad. Fighting with elegance and precision is far more enriching than slugging an enemy with a club or butchering an enemy with a sword. Learn the art of Fencing if you want to master combat and look good doing it! The key to being a successful fencer is to be the complement and not the opposition to your opponent's strength. Watch for your opponent to become off balance. Then finish him off with finesse and flair. There are some undead that need cleansing out in Old Haven towards the East. Head over there and slay them, but remember, do it with style! Come back to me once you have achieved the rank of Apprentice Fencer, and I will reward you with a prize.", + "uncomplete": "You're doing well so far, but you're not quite ready yet. Head back to Old Haven, to the East, and kill some more undead.", + "complete": "Excellent! You are beginning to appreciate the art of Fencing. I told you fighting with elegance and precision is more enriching than fighting like an ogre. Since you have returned victorious, please take this war fork and use it well. The war fork is a finesse weapon, and this one is magical! I call it 'Recaro's Riposte.' With it, you will be able to parry and counterstrike with ease! Your enemies will bask in your greatness and glory! Good luck to you, lad, and keep practicing!", + "refuse": "I understand, lad. Being a hero isn't for everyone. Run along, then. Come back to me if you change your mind.", + "oncomplete": "You have achieved the rank of Apprentice Fencer. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel more dexterous and quick-witted while practicing combat here. Your ability to raise your Fencing skill is enhanced in this area.", + "npcPhrases": [ + "Precision and style turn the tide of battle.", + "The art of fencing requires a dexterous hand, a quick wit and fleet feet." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 212, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "recarosriposte", "amount": 1, "name": "Recaro's Riposte" } + ] + }, + "27": { + "title": "Swift as an Arrow", + "description": "Head East out of town and go to Old Haven. While wielding your bow or crossbow, battle monsters there until you have raised your Archery skill to 50. Well met, friend. Imagine yourself in a distant grove of trees. You raise your bow, take slow, careful aim, and with the twitch of a finger, you impale your prey with a deadly arrow. You look like you would make an excellent archer, but you will need practice. There is no better way to practice Archery than when your life is on the line. I have a challenge for you. Head East out of town and go to Old Haven. While wielding your bow or crossbow, battle the undead that reside there. Make sure you bring a healthy supply of arrows (or bolts if you prefer a crossbow). If you wish to purchase a bow, crossbow, arrows, or bolts, you can purchase them from me or the Archery shop in town. You can also make your own arrows with the Bowcraft/Fletching skill. You will need fletcher's tools, wood to turn into shafts, and feathers to make arrows or bolts. Come back to me after you have achieved the rank of Apprentice Archer, and I will reward you with a fine Archery weapon.", + "uncomplete": "You're doing great as an Archer! However, you need more practice. Head East out of town and go to Old Haven. Come back to me after you have achieved the rank of Apprentice Archer.", + "complete": "Congratulations! I want to reward you for your accomplishment. Take this composite bow. It is called 'Heartseeker.' With it, you will shoot with swiftness, precision, and power. I hope 'Heartseeker' serves you well.", + "refuse": "I understand that Archery may not be for you. Feel free to visit me in the future if you change your mind.", + "oncomplete": "You have achieved the rank of Apprentice Archer. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel more steady and dexterous here. Your Archery skill is enhanced in this area.", + "npcPhrases": [ + "Master the bow, and let your arrows fly true.", + "Archery requires a steady aim and dexterous fingers." + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 213, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "heartseeker", "amount": 1, "name": "Heartseeker" } + ] + }, + "28": { + "title": "Thou and Thine Shield", + "description": "Head East out of town and go to Old Haven. Battle monsters, or simply let them hit you, while holding a shield or a weapon until you have raised your Parrying skill to 50. Oh, hello. You probably want me to teach you how to parry, don't you? Very well. First, you'll need a weapon or a shield. Obviously, shields work best of all, but you can parry with a 2-handed weapon. Or if you're feeling particularly brave, a 1-handed weapon will do in a pinch. I'd advise you to go to Old Haven, which you'll find to the East, and practice blocking incoming blows from the undead there. You'll learn quickly if you have more than one opponent attacking you at the same time to practice parrying lots of blows at once. That's the quickest way to master the art of parrying. If you manage to improve your skill enough, I have a shield that you might find useful. Come back to me when you've trained to an apprentice level.", + "uncomplete": "You're doing well, but in my opinion, I don't think you really want to continue on without improving your parrying skill a bit more. Go to Old Haven, to the East, and practice blocking blows with a shield.", + "complete": "Well done! You're much better at parrying blows than you were when we first met. You should be proud of your new ability, and I bet your body is grateful to you as well. *Tyl Ariadne laughs loudly at his own (mostly lame) joke* Oh yes, I did promise you a shield if I thought you were worthy of having it, so here you go. My father made these shields for the guards who served my father faithfully for many years, and I just happen to have one that I can part with. You should find it useful as you explore the lands. Good luck, and may the Virtues be your guide.", + "refuse": "It's your choice, obviously, but I'd highly suggest that you learn to parry before adventuring out into the world. Come talk to me again when you get tired of being beaten on by your opponents.", + "oncomplete": "You have achieved the rank of Apprentice Parrier. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "You feel as light as a butterfly, as if you could block incoming blows easily. Your ability to hone your Parrying skill is enhanced in this area.", + "npcPhrases": [ + "A shield isn't just defense; it's your partner in battle.", + "Want to learn how to parry blows ?" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 214, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "escutcheondeariadne", "amount": 1, "name": "Escutcheon de Ariadne" } + ] + }, + "29": { + "title": "Cleansing Old Haven", + "description": "Head East out of town to Old Haven. Consecrate your weapon, cast Divine Fury, and battle monsters there until you have raised your Chivalry skill to 50. Hail, friend. The life of a Paladin is a life of much sacrifice, humility, bravery, and righteousness. If you wish to pursue such a life, I have an assignment for you. Adventure east to Old Haven, consecrate your weapon, and lay to rest the undead that inhabit there. Each ability a Paladin wishes to invoke will require a certain amount of 'tithing points' to use. A Paladin can earn these tithing points by donating gold at a shrine or holy place. You may tithe at this shrine. Return to me once you feel that you are worthy of the rank of Apprentice Paladin.", + "uncomplete": "There are still more undead to lay to rest. You still have more to learn. Return to me once you have done so.", + "complete": "Well done, friend. While I know you understand Chivalry is its own reward, I would like to reward you with something that will protect you in battle. It was passed down to me when I was a lad. Now, I am passing it on to you. It is called the Bulwark Leggings. Thank you for your service.", + "refuse": "Farewell to you, my friend. Return to me if you wish to live the life of a Paladin.", + "oncomplete": "You have achieved the rank of Apprentice Paladin. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Chivalry potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "The righteous path is not easy, but it is always worth walking.", + "Hail, friend. Want to live the life of a paladin?" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 215, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "bulwarkleggings", "amount": 1, "name": "Bulwark Leggings" } + ] + }, + "30": { + "title": "The Rudiments of Self Defense", + "description": "Head East out of town and go to Old Haven. Battle monsters there until you have raised your Wrestling skill to 50. Listen up! If you want to learn the rudiments of self-defense, you need toughening up, and there's no better way to toughen up than engaging in combat. Head East out of town to Old Haven and battle the undead there in hand-to-hand combat. Afraid of dying, you say? Well, you should be! Being an adventurer isn't a bed of posies, or roses, or however that saying goes. If you take a dirt nap, go to one of the nearby wandering healers and they'll get you back on your feet. Come back to me once you feel that you are worthy of the rank Apprentice Wrestler and I will reward you with a prize.", + "uncomplete": "You have not achieved the rank of Apprentice Wrestler. Come back to me once you feel that you are worthy of the rank Apprentice Wrestler and I will reward you with something useful.", + "complete": "It's about time! Looks like you managed to make it through your self-defense training. As I promised, here's a little something for you. When worn, these Gloves of Safeguarding will increase your awareness and resistances to most elements except poison. Oh yeah, they also increase your natural health regeneration as well. Pretty handy gloves, indeed. Oh, if you are wondering if your meditation will be hindered while wearing these gloves, it won't be. Mages can wear cloth and leather items without needing to worry about that. Now get out of here and make something of yourself.", + "refuse": "Ok, featherweight. Come back to me if you want to learn the rudiments of self-defense.", + "oncomplete": "You have achieved the rank of Apprentice Wrestler. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Wrestling potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "The best defense is a strong offense... and a lot of practice.", + "You there! Wanna master hand to hand defense? Of course you do!" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 216, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "glovesofsafeguarding", "amount": 1, "name": "Gloves of Safeguarding" } + ] + }, + "31": { + "title": "Channeling the Supernatural", + "description": "Head East out of town and go to Old Haven. Use Spirit Speak and channel energy from either yourself or nearby corpses there. You can also cast Necromancy spells to raise Spirit Speak. How do you do? Channeling the supernatural through Spirit Speak allows you to heal your wounds. Such channeling expends your mana, so be mindful of this. Spirit Speak enhances the potency of your Necromancy spells. The channeling powers of a Medium are quite useful when practicing the dark magic of Necromancy. It is best to practice Spirit Speak where there are a lot of corpses. Head East out of town and go to Old Haven. Undead currently reside there. Use Spirit Speak and channel energy from either yourself or nearby corpses. You can also cast Necromancy spells to raise Spirit Speak. Come back to me once you feel that you are worthy of the rank of Apprentice Medium, and I will reward you with something useful.", + "uncomplete": "Back so soon? You have not achieved the rank of Apprentice Medium. Come back to me once you feel that you are worthy of the rank of Apprentice Medium, and I will reward you with something useful.", + "complete": "Well done! Channeling the supernatural is taxing, indeed. As promised, I will reward you with this bag of Necromancer reagents. You will need these if you wish to also pursue the dark magic of Necromancy. Good journey to you.", + "refuse": "Channeling the supernatural isn't for everyone. It is a dark art. See me if you ever wish to pursue the life of a Medium.", + "oncomplete": "You have achieved the rank of Apprentice Medium. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your ability to channel the supernatural is greatly enhanced while questing in this area.", + "npcPhrases": [ + "The line between life and death is thin; learn to walk it wisely.", + "Want to learn how to channel the supernatural?" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 217, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "bagofnecroreagents", "amount": 1, "name": "Bag of Necromancer Reagents" } + ] + }, + "32": { + "title": "The Allure of Dark Magic", + "description": "Head East out of town and go to Old Haven. Cast Evil Omen and Pain Spike against monsters there until you have raised your Necromancy skill to 50. Welcome! I see you are allured by the dark magic of Necromancy. First, you must prove yourself worthy of such knowledge. Undead currently occupy the town of Old Haven. Practice your harmful Necromancy spells on them such as Evil Omen and Pain Spike. Make sure you have plenty of reagents before embarking on your journey. Reagents are required to cast Necromancy spells. You can purchase extra reagents from me, or you can find reagents growing in the nearby wooded areas. You can see which reagents are required for each spell by looking in your spellbook. Come back to me once you feel that you are worthy of the rank of Apprentice Necromancer and I will reward you with the knowledge you desire.", + "uncomplete": "You have not achieved the rank of Apprentice Necromancer. Come back to me once you feel that you are worthy of the rank of Apprentice Necromancer and I will reward you with the knowledge you desire.", + "complete": "You have done well, my young apprentice. Behold! I now present to you the knowledge you desire. This spellbook contains all the Necromancer spells. The power is intoxicating, isn't it?", + "refuse": "You are weak after all. Come back to me when you are ready to practice Necromancy.", + "oncomplete": "You have achieved the rank of Apprentice Necromancer. Return to your instructor in Old Haven to claim your reward.", + "trainingarea": "Your Necromancy potential is greatly enhanced while questing in this area.", + "npcPhrases": [ + "True power lies in the mastery of the dark arts.", + "Allured by dark magic, aren't you?" + ], + "type": "skillgain", + "category": "Side Quests", + "oneTimeQuest": 1, + "questTurnIn": 1, + "bankgold": 0, + "targetRegion": 218, + "regionName": "Old Haven Training", + "targetSkill": 50, + "maxSkillPoints": 500, + "minPoint": 2, + "maxPoint": 5, + "rewards": [ + { "type": "item", "sectionID": "completenecromancerspellbook", "amount": 1, "name": "Complete Necromancer Spellbook" } + ] + }, + "33": { + title: "Split Ends", + description: "*sighs* I think bowcrafting is a might beyond my talents. Say there, you look a bit more confident with tools. Can I persuade thee to make a few arrows? You could have my satchel in return... 'tis useless to me! You'll need a fletching kit to start, some feathers, and a few arrow shafts.Just use the fletching kit while you have the other things, and I'm sure you'll figure out the rest.", + uncomplete: "You're not quite done yet. Get back to work!", + complete: "Thanks for helping me out. Here's the reward I promised you.", + refuse: "Oh. Well. I'll just keep trying alone, I suppose...", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "arrow", amount: 20, name: "arrow" } + ], + rewards: [ + { type: "item", sectionID: "fletcherssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "34": { + title: "Split Ends", + description: "*sighs* I think bowcrafting is a might beyond my talents. Say there, you look a bit more confident with tools. Can I persuade thee to make a few arrows? You could have my satchel in return... 'tis useless to me! You'll need a fletching kit to start, some feathers, and a few arrow shafts.Just use the fletching kit while you have the other things, and I'm sure you'll figure out the rest.", + uncomplete: "You're not quite done yet. Get back to work!", + complete: "Thanks for helping me out. Here's the reward I promised you.", + refuse: "Oh. Well. I'll just keep trying alone, I suppose...", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "arrow", amount: 20, name: "arrow" } + ], + rewards: [ + { type: "item", sectionID: "fletcherssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "35": { + title: "I Shot an Arrow Into the Air...", + description: "Truth be told, the only way to get a feel for the bow is to shoot one and there's no better practice target than a sheep. If ye can shoot ten of them I think ye will have proven yer abilities. Just grab a bow and make sure to take enough ammunition. Bows tend to use arrows and crossbows use bolts.Ye can buy 'em or have someone craft 'em. How about it then? Come back here when ye are done.", + uncomplete: "Return once ye have killed ten sheep with a bow and not a moment before.", + complete: "Well done! I prepared this bag for thee. Take it, and I wish thee the best of luck in thy endeavors.", + refuse: "Fair enough, the bow isn't for everyone. Good day then.", + type: "kill", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetKills: [ + { npcID: "sheep", amount: 10, name: "sheep" } + ], + rewards: [ + { type: "item", sectionID: "trinketbag", amount: 1, name: "A bag of trinkets." } + ] + }, + "36": { + title: "Comfortable Seating", + description: "Hail friend, hast thou a moment? A mishap with a saw hath left me in a sorry state, for it shall be a while before I canst return to carpentry.In the meantime, I need a comfortable chair that I may rest. Could thou craft a straw chair? Only a tool, such as a dovetail saw, a few boards, and some skill as a carpenter is needed. Remember, this is a piece of furniture, so please pay attention to detail.", + uncomplete: "Is all going well? I look forward to the simple comforts in my very own home.", + complete: "This is perfect!", + refuse: "I quite understand your reluctance. If you reconsider, I'll be here.", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "BambooChair", amount: 1, name: "Straw Chair" } + ], + rewards: [ + { type: "item", sectionID: "carpenterssatchel", amount: 1, name: "Craftsman's Satchel" } + ] + }, + "37": { + title: "The Pen is Mightier", + description: "Do you know anything about 'Inscription?' I've been trying to get my hands on some hand crafted Recall scrolls for a while now, and I could really use some help. I don't have a scribe's pen, let alone a spellbook with Recall in it, or blank scrolls, so there's no way I can do it on my own. How about you though ? I could trade you one of my old leather bound books for some.", + uncomplete: "Inscribing... yes, you'll need a scribe's pen, some reagents, some blank scroll, and of course your own magery book.You might want to visit the magery shop if you're lacking some materials.", + complete: "Ha! Finally! I've had a rune to the waterfalls near Justice Isle that I've been wanting to use for the longest time, and now I can visit at last. Here's that book I promised you... glad to be rid of it, to be honest.", + refuse: "Hmm, thought I had your interest there for a moment. It's not everyday you see a book made from real daemon skin, after all!", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "recallscroll", amount: 5, name: "Recall Scroll" } + ], + rewards: [ + { type: "item", sectionID: "redleatherbook", amount: 1, name: "a book bound in red leather" } + ] + }, + "38": { + title: "Flee and Fatigue", + description: "I was just *coughs* ambushed near the moongate. *wheeze* Why do I pay my taxes? Where were the guards? You then, you an Alchemist? If you can make me a few Refresh potions, I will be back on my feet and can give those lizards the what for! Find a mortar and pestle, a good amount of black pearl, and ten empty bottles to store the finished potions in. Just use the mortar and pestle and the rest will surely come to you.When you return, the favor will be repaid.", + uncomplete: "Just remember you need to use your mortar and pestle while you have empty bottles and some black pearl. Refresh potions are what I need.", + complete: "*glug* *glug* Ahh... Yes! Yes! That feels great! Those lizardmen will never know what hit 'em! Here, take this, I can get more from the lizards.", + refuse: "Fine fine, off with *cough* thee then! The next time you see a lizardman though, give him a whallop for me, eh?", + type: "collect", + category: "Side Quests", // New field for quest categorization + oneTimeQuest: 1, // Indicates this quest can only be completed once + questTurnIn: 1, // 0 = auto-complete + bankgold: 0, + targetItems: [ + { sectionID: "refreshpotion", amount: 10, name: "Refresh Potion" } + ], + rewards: [ + { type: "item", sectionID: "alchemistssatchel", amount: 1, name: "a book bound in red leather" } + ] + }, + //Custom Example Quests + "1000": { + title: "Collect Fruits", + description: "Collect 2 apples, 3 oranges, and 1 banana.", + uncomplete: "You have not collected all the fruits yet.", + complete: "You have collected all the fruits! Here is your reward.", + refuse: "You have refused to collect the fruits.", + npcPhrases: [ + "Bring me iron, and I'll reward you handsomely.", + "Have you collected the ore yet?", + "Don't keep me waiting for those materials!" + ], + type: "collect", + questTurnIn: 1, // 0 = auto-complete + targetItems: [ + { sectionID: "0x13e3", amount: 2, name: "Apples" }, // Apples + { sectionID: "0x13e4", amount: 3, name: "Oranges" }, // Oranges + { sectionID: "0x13e5", amount: 1, name: "Bananas" } // Bananas + ], + rewards: [ + { type: "gold", amount: 500, name: "Gold Coins" }, + { type: "item", sectionID: "0x1f4c", amount: 1, name: "Magical Scroll" }, + { type: "karma", amount: 50, name: "Karma Points" }, + { type: "fame", amount: 30, name: "Fame Points" } + ] + }, + "1001": { + title: "Hunt Dangerous Creatures", + description: "Kill 1 dog and 1 cat.", + type: "kill", + targetKills: [ + { npcID: "dog", amount: 1, name: "dog" }, // Kill 5 orcs + { npcID: "cat", amount: 1, name: "cat" } // Kill 3 cats + ], + rewards: [ + { type: "gold", amount: 1000, name: "gold" }, + { type: "karma", amount: 50, name: "karma" }, + { type: "fame", amount: 30, name: "fame" } + ] + }, + "1002": { + title: "Collect Fruits", + type: "collect", + targetItems: [ + { sectionID: "0x13e3", amount: 2 }, // Apples + ], + rewards: [ + { type: "gold", amount: 500 }, // Give 500 gold + { type: "item", sectionID: "0x1f4c", amount: 1 }, // Give a magical scroll + { type: "karma", amount: 50 }, // Add 50 karma + { type: "fame", amount: 30 } // Add 30 fame + ] + }, + "1003": { + title: "Gather Fruits and Hunt Animals", + type: "multi", // New type for combined objectives + targetItems: [ + { sectionID: "0x13e3", amount: 1 } // Collect 3 apples + ], + targetKills: [ + { npcID: "dog", amount: 1 }, // Kill 1 dog + ], + rewards: [ + { type: "gold", amount: 500 }, // Give 500 gold + { type: "item", sectionID: "0x1f4c", amount: 1 }, // Give a magical scroll + { type: "karma", amount: 50 }, // Add 50 karma + { type: "fame", amount: 30 } // Add 30 fame + ] + }, + "1004": { + title: "Timed Fruit Collection", + type: "timecollect", + targetItems: [ + { sectionID: "0x13e3", amount: 5 } // Collect 5 apples + ], + timeLimit: 300, // Time limit in seconds + rewards: [ + { type: "gold", amount: 100 }, + { type: "karma", amount: 10 } + ] + }, + "1005": { + title: "Timed Hunt Animals", + type: "timekills", + targetKills: [ + { sectionID: "dog", amount: 1 }, // Kill 1 dog + ], + timeLimit: 100, // Time limit in seconds + rewards: [ + { type: "gold", amount: 100 }, + { type: "karma", amount: 10 } + ] + }, + "1006": { + title: "Gather Apples", + description: "Collect 10 apples from the orchard.", + type: "collect", + questTurnIn: 1, // 0 = auto-complete + targetItems: [{ sectionID: "0x13e3", amount: 1, name: "Apple" }], + rewards: [{ type: "gold", amount: 500 }], + nextQuestID: "1007" // Chain to the next quest + }, + "1007": { + title: "Defeat the Bandits", + description: "Defeat 5 bandits in the nearby forest.", + type: "kill", + questTurnIn: 1, // 0 = auto-complete + targetKills: [{ sectionID: "cat", amount: 1 }], + rewards: [{ type: "gold", amount: 1000 }], + nextQuestID: "1008" // Chain to the next quest + }, + "1008": { + title: "Deliver Supplies to the Guard", + description: "Bring the supplies to the guard at the eastern gate.", + type: "delivery", + targetDelivery: { sectionID: "nail", location: { x: 1068, y: 1608, z: 0, "world": 0 } }, + deliveryItem: { sectionID: "0x0E21", amount: 1, name: "Sealed Package" }, + rewards: [ + { type: "gold", amount: 1000 }, + { type: "item", sectionID: "0x1B7A", amount: 1 } + ], + nextQuestID: null + }, + "1011": { + "title": "Deliver the Package to Nail", + "type": "delivery", + "description": "Deliver the package to Nail, the receiver, located at his usual spot.", + "deliveryItem": { + "sectionID": "0x0E21", // Package item ID + "name": "Sealed Package", // Friendly name for the item + "amount": 1 // Quantity + }, + "targetDelivery": { + "sectionID": "nail", // Section ID of Nail + "name": "Nail the Receiver", // Name of the NPC + "location": { + "x": 1068, // X coordinate + "y": 1608, // Y coordinate + "z": 0, // Z coordinate + "world": 0 // World number + } + }, + "rewards": [ + { "type": "gold", "amount": 500 }, + { "type": "item", "sectionID": "0x1F4C", "amount": 1 } + ] + }, + "1012": { + title: "Daily Mining Challenge", + description: "Mine 50 ore in the designated area.", + type: "collect", + category: "Daily Quests", + questTurnIn: 1, // 0 = auto-complete + dailyQuest: 1, + resetDailyTime: 24, // Reset after 24 hours + targetItems: [{ sectionID: "ore", amount: 50, name: "Iron Ore"}], // Ore ID + rewards: [ + { type: "gold", amount: 2000, name: "Gold" }, + { type: "item", sectionID: "0x1F4C", amount: 1, name: "Recall Scroll" } + ] + }, + "9001": { + title: "Urgent Delivery", + description: "A mysterious package has been left for you. Deliver it to Nail in Britain.", + type: "delivery", + deliveryItem: { + sectionID: "0x09a9", + name: "Sealed Package", + amount: 1 + }, + "targetDelivery": { + "sectionID": "nail", // Section ID of Nail + "name": "Nail the Receiver", // Name of the NPC + "location": { + "x": 1068, // X coordinate + "y": 1608, // Y coordinate + "z": 0, // Z coordinate + "world": 0 // World number + } + }, + rewards: [ + { type: "gold", amount: 500, name: "Gold" }, + // +5.0 Magery (by name) + { type: "skill", skill: "Magery", amount: 5.0 }, + // +2.5 Tactics (by numeric ID) + { type: "skill", skill: 27, amount: 2.5 }, + // +10.0 pooled points + { type: "skillpoints", amount: 10.0 }, + { type: "item", sectionID: "reward_ring_of_swiftness", name: "Ring of Swiftness", amount: 1 } + ], + oneTimeQuest: 1 + }, + "1200": { + title: "Fork in the Road", + description: + "Your first real choice begins here. Prove your intent, and the right people will find you.\n\n" + + "Bring me a simple token to show you can follow instructions: 10 apples.\n\n" + + "Depending on the life youve chosen, this will open different opportunities.", + uncomplete: "You still havent brought the 10 apples. Come back when you have them.", + complete: + "Good. You can follow directions. Now lets see where your choices have placed you.", + refuse: "Not ready? Thats fine. Return when you want to choose your path.", + type: "collect", + category: "Side Quests", + oneTimeQuest: 1, + questTurnIn: 1, + bankgold: 0, + + targetItems: [ + { sectionID: "0x09D0", amount: 10, name: "Apple" } // Apple + ], + + rewards: [ + { type: "gold", amount: 250, name: "Gold" } + ], + + // Branching next quest rules - MATCHES your resolver: + // nextQuest: [ { questID, cond }, ... ] + nextQuest: [ + // Thief route (tag set somewhere else in your shard logic) + { questID: 1201, cond: { tagEquals: { tag: "DL_ThiefPath", value: 1 } } }, + + // Guard/Law route + { questID: 1202, cond: { tagMin: { tag: "DL_GuardPath", value: 1 } } }, + + // Default fallback route (cond omitted => passes) + { questID: 1203 } + ], + + // Optional: legacy single-step fallback (only used if nextQuest[] missing) + nextQuestID: 1203 + }, + + "1201": { + title: "A Quiet Hand", + description: + "Word travels fast in the shadows.\n\n" + + "Bring me 5 bandages. No questions. No explanations.", + uncomplete: "Still waiting. 5 bandages.", + complete: "Good. You understand discretion.", + refuse: "Then youre not ready for the shadows.", + type: "collect", + category: "Side Quests", + oneTimeQuest: 1, + questTurnIn: 1, + bankgold: 0, + + targetItems: [ + { sectionID: "0x0E21", amount: 5, name: "Bandage" } + ], + + rewards: [ + { type: "item", sectionID: "bag", amount: 1, name: "A small pouch" }, + { type: "gold", amount: 300, name: "Gold" } + ], + + // Chain example: after thief quest, open a follow-up only if not already completed + nextQuest: [ + { questID: 1210, cond: { notCompletedQuest: 1210 } } + ] + }, + + "1202": { + title: "Citizens Duty", + description: + "The watch needs reliable hands.\n\n" + + "Bring 5 clean bandages for the infirmary. This is honest work.", + uncomplete: "Return with 5 clean bandages for the infirmary.", + complete: "Well done. Duty is built from small acts.", + refuse: "The watch wont beg. Return if you reconsider.", + type: "collect", + category: "Side Quests", + oneTimeQuest: 1, + questTurnIn: 1, + bankgold: 0, + + targetItems: [ + { sectionID: "0x0E21", amount: 5, name: "Bandage" } + ], + + rewards: [ + { type: "gold", amount: 300, name: "Gold" } + ], + + nextQuest: [ + // Only unlock the next duty quest if the player hasn't done it already + { questID: 1203, cond: { notCompletedQuest: 1204 } } + ] + }, + + "1203": { + title: "Simple Work, Simple Pay", + description: + "No drama. No factions.\n\n" + + "Bring me 20 logs and Ill pay you fairly.", + uncomplete: "Still need 20 logs.", + complete: "Fair work, fair pay. Good job.", + refuse: "Suit yourself.", + type: "collect", + category: "Side Quests", + oneTimeQuest: 1, + questTurnIn: 1, + bankgold: 0, + + targetItems: [ + { sectionID: "0x1BE0", amount: 20, name: "Log" } + ], + + rewards: [ + { type: "gold", amount: 400, name: "Gold" } + ] + }, + "1204": { + title: "Gather Apples", + description: + "No drama. No factions.\n\n" + + "Bring me 20 logs and Ill pay you fairly.", + type: "collect", + questTurnIn: 1, + targetItems: [{ sectionID: "0x0E21", amount: 10, name: "Apple" }], + rewards: [{ type: "gold", name: "gold", amount: 500 }], + + // NEW: world-state consequences (no player tags) + setWorldState: { + "dl_ws_orchard_helped": 1, + "dl_ws_orchard_lasthelp": 0 // you can overwrite counters too + }, + + worldStateDeltas: { + "dl_ws_ironreach_rep": 5, + "dl_ws_compassion": 10 + }, + + worldStateDeltaRulesDefault: { min: 0, max: 1000 }, // clamp default + worldStateDeltaRules: { + "dl_ws_ironreach_rep": { min: -100, max: 100 } + }, + + nextQuest: [ + { questID: 1203, cond: { worldStateEquals: { key: "dl_ws_joinedthieves", value: 1 } } }, + { questID: 1202, cond: { always: true } } + ] + }, + "14000": { + title: "A Candle in the Dark", + description: "Mortis needs wards lit before the night turns hungry.", + uncomplete: "Bring me 10 candles. The wind is rising.", + complete: "Good. The first ward holds.", + refuse: "Then keep your lantern close.", + type: "collect", + category: "Seasonal: Halloween", + oneTimeQuest: 1, + questTurnIn: 1, + targetItems: [{ sectionID: "0x0A28", amount: 10, name: "Candle" }], + rewards: [{ type: "gold", amount: 500, name: "Gold" }], + nextQuest: [ + { questID: 14001, cond: { always: true } } + ] + }, + "14001": { + title: "The Pumpkin Debt", + description: "Bring 5 pumpkins for the offering circle.", + uncomplete: "I still need 5 pumpkins.", + complete: "The circle is fed.", + type: "collect", + category: "Seasonal: Halloween", + oneTimeQuest: 1, + questTurnIn: 1, + targetItems: [{ sectionID: "0x0C6A", amount: 5, name: "Pumpkin" }], + rewards: [{ type: "item", sectionID: "0x1F09", amount: 1, name: "Halloween Token" }] + } + }; + + // Return the full quest list if no questID is provided + if( !questID ) + { + return questList; + } + + // Return a specific quest if questID is provided + return questList[questID] || null; +} \ No newline at end of file diff --git a/data/js/server/quest/quest_login.js b/data/js/server/quest/quest_login.js new file mode 100644 index 000000000..0033fc6a2 --- /dev/null +++ b/data/js/server/quest/quest_login.js @@ -0,0 +1,45 @@ +/// +// @ts-check + +/** @type { ( pChar: Character ) => void } */ +function LoginQuest( pChar ) +{ + var autoQuestID = 20000; // Insert Starting Quest ID here + + // Load settings and progress + var playerSettings = TriggerEvent( 5800, "ReadPlayerSettings", pChar ) || {}; + var showLoginQuests = playerSettings["ShowLoginQuestOffers"] !== false; + var seenQuests = playerSettings["SeenQuests"] || {}; + var progress = TriggerEvent( 5800, "ReadQuestProgress", pChar ); + var archived = TriggerEvent( 5800, "ReadArchivedQuests", pChar ); + + var alreadyHas = false; + for( var i = 0; i < progress.length; i++ ) + { + if( progress[i].questID == autoQuestID ) + { + alreadyHas = true; + break; + } + } + + for( var i = 0; i < archived.length; i++ ) + { + if( parseInt( archived[i], 10 ) == autoQuestID ) + { + alreadyHas = true; + break; + } + } + + // Show the gump if eligible + if( showLoginQuests && !alreadyHas && !seenQuests[autoQuestID] ) + { + seenQuests[autoQuestID] = true; + playerSettings["SeenQuests"] = seenQuests; + TriggerEvent( 5800, "SavePlayerSettings", pChar, playerSettings ); + + TriggerEvent( 5802, "QuestConversationGump", pChar, null, autoQuestID ); + pChar.SetTempTag( "questConversationID", autoQuestID ); + } +} \ No newline at end of file diff --git a/data/js/server/quest/quest_menu.js b/data/js/server/quest/quest_menu.js new file mode 100644 index 000000000..7210f9fef --- /dev/null +++ b/data/js/server/quest/quest_menu.js @@ -0,0 +1,409 @@ +/// +// @ts-check + +/** @type { ( pUser: Character ) => void } */ +function QuestMenu( pUser ) +{ + if( !ValidateObject( pUser )) + return; + + var socket = pUser.socket; + if( socket == null ) + return; + + var playerSerial = pUser.serial; + + // Load active and archived quests + var activeQuests = TriggerEvent( 5800, "ReadQuestProgress", pUser ) || []; + + // Load player settings for the preferred category + var playerSettings = TriggerEvent( 5800, "ReadPlayerSettings", pUser ) || {}; + var currentCategory = playerSettings["QuestLogCategory"] || "All Quests"; + var useTransparentBackground = playerSettings["UseTransparentBackground"] || false; + + // Define categories + var categories = ["All Quests", "Main Story Quest", "Side Quests", "Event Quests", "Daily Quests", "World Quests", "City Quests"]; + var filteredQuests = []; + for( var i = 0; i < activeQuests.length; i++ ) + { + var questEntry = activeQuests[i]; + + // Check if this quest belongs to the current player + if( questEntry.serial != playerSerial ) + continue; + + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + + if( quest ) + { + // Trim and normalize categories for comparison + var questCategory = ( quest.category || "" ).trim().toLowerCase(); + var playerCategory = ( currentCategory || "" ).trim().toLowerCase(); + + if( playerCategory == "all quests" || questCategory == playerCategory ) + { + filteredQuests.push( questEntry ); + } + } + } + + var questsPerPage = 10; // Quests displayed per page + var totalPages = Math.ceil( filteredQuests.length / questsPerPage ); + + var mainQuestMenu = new Gump(); + mainQuestMenu.AddPage( 0 ); + mainQuestMenu.AddBackground( 30, 120, 296, 447, 1579 ); // Main background + var titleHue = 500; + // Background and transparency toggle + if( useTransparentBackground ) + { + mainQuestMenu.AddCheckerTrans( 30, 120, 296, 447 ); // Transparent background + titleHue = 1160; + } + mainQuestMenu.AddGump( 70, 130, 1577 ); + mainQuestMenu.AddText( 135, 200, titleHue, "Quest Journal" ); + mainQuestMenu.AddButton(50, 100, 1588, 1589, 1, 0, 20 ); // Active Quests Button + mainQuestMenu.AddText( 65, 102, 500, "Active Quests" ); + mainQuestMenu.AddButton(180, 100, 1588, 1589, 1, 0, 21 ); // Completed Quests Button + mainQuestMenu.AddText( 193, 102, 0, "Completed Quests" ); + + // Adjust the quest list start position below the category buttons + var questListStartY = 110 + categories.length * 16; + + // Add pagination for filtered quests + for( var page = 0; page < totalPages; page++ ) + { + mainQuestMenu.AddPage( page + 1 ); + + var startIndex = page * questsPerPage; + var endIndex = Math.min( startIndex + questsPerPage, filteredQuests.length ); + + for( var j = startIndex; j < endIndex; j++ ) + { + var questEntry = filteredQuests[j]; + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + var buttonY = questListStartY + (( j % questsPerPage ) * 25 ); // Reduced spacing + + if( !quest ) + continue; + + // Add quest name with a navigation button for details + mainQuestMenu.AddPageButton( 40, buttonY, 1141, 1143, totalPages + j + 1 ); // Navigate to quest details + mainQuestMenu.AddText( 80, buttonY + 5, 0, quest.title ); + } + + // Navigation buttons for quest list pages + if( page > 0 ) + { + mainQuestMenu.AddButton( 50, 450, 1144, 1145, page, 0, 0 ); // Previous Page Button + mainQuestMenu.AddText( 80, 455, 0, "Previous" ); + } + + if( page < totalPages - 1 ) + { + mainQuestMenu.AddButton( 200, 450, 1144, 1145, page + 2, 0, 0 ); // Next Page Button + mainQuestMenu.AddText( 230, 455, 0, "Next" ); + } + + mainQuestMenu.AddPageButton( 40, 550, 1588, 1589, totalPages + filteredQuests.length + 1 ); // Options Menu Button + mainQuestMenu.AddText( 65, 555, 0, "Options Menu" ); + } + + // Add quest details pages + for( var m = 0; m < filteredQuests.length; m++ ) + { + var questEntry = filteredQuests[m]; + var quest = TriggerEvent( 5801, "QuestList", questEntry.questID ); + + if( !quest ) + continue; + + mainQuestMenu.AddPage( totalPages + m + 1 ); + + // Add the quest details background + mainQuestMenu.AddBackground( 350, 120, 306, 350, 1579 ); // Details background + if( useTransparentBackground ) + { + mainQuestMenu.AddCheckerTrans( 350, 120, 306, 350 ); // Transparent background + } + mainQuestMenu.AddHTMLGump( 47, 220, 266, 300, true, false, + "Welcome to the Quest Details page!

" + + "Here you can view all the information about the selected quest:
" + + "- Description: A detailed summary of what the quest is about.
" + + "- Objectives: A list of tasks or goals you need to complete to finish the quest. These may include collecting items, defeating enemies, or reaching a specific skill level.
" + + "- Rewards: Displays the rewards you will receive upon completing the quest, such as gold, items, or experience.

" ); + + mainQuestMenu.AddText( 440, 130, titleHue, quest.title ); + + // Add quest description + var description = quest.description || "No description available."; + mainQuestMenu.AddHTMLGump( 370, 160, 266, 92, true, true, description ); // Description + + // Add objectives + var objectives = TriggerEvent( 5802, "GetQuestObjectives", quest, questEntry ) || "No objectives available."; + mainQuestMenu.AddHTMLGump( 370, 260, 266, 81, true, true, objectives ); + + // Add rewards + var rewards = TriggerEvent( 5802, "GetQuestRewards", quest ) || "No rewards available."; + mainQuestMenu.AddHTMLGump( 370, 350, 266, 100, true, true, rewards ); + + // Add a back button to return to the main quest list + var originalPage = Math.floor( m / questsPerPage ) + 1; + mainQuestMenu.AddPageButton( 225, 530, 247, 249, originalPage ); // Back Button + } + + // Add Options Menu Page + mainQuestMenu.AddPage( totalPages + filteredQuests.length + 1 ); + mainQuestMenu.AddBackground( 350, 120, 306, 400, 1579 ); // Options Menu Background + mainQuestMenu.AddPageButton( 40, 550, 1588, 1589, totalPages + filteredQuests.length + 1 ); // Options Menu Button + mainQuestMenu.AddText( 65, 555, 500, "Options Menu" ); + var wordHue = 0; + if( useTransparentBackground ) + { + mainQuestMenu.AddCheckerTrans( 350, 120, 306, 400 ); // Transparent background + wordHue = 1152; + } + mainQuestMenu.AddHTMLGump( 47, 230, 266, 300, true, false, + "Welcome to the Quest Journal Options!

" + + "Here you can customize how your quests are displayed and how your journal looks:
" + + "- Quest Categories: Filter your active quests by type, such as Main Story, Side Quests, or Event Quests.
" + + "- Background Transparency: Toggle the background of your journal between solid and transparent for better readability.

" + + "Make your selections by clicking the buttons beside." ); + mainQuestMenu.AddText(420, 130, titleHue, "Options Menu" ); + mainQuestMenu.AddBackground(365, 150, 280, 22, 9350); + mainQuestMenu.AddText( 370, 150, 0, "Misc Options" ); + // Transparency toggle + var transparencyToggleText = useTransparentBackground ? "Disable Transparency" : "Enable Transparency"; + //mainQuestMenu.AddButton( 370, 280, 1141, 1143, 1, 0, 2001 ); // Toggle transparency + mainQuestMenu.AddCheckbox(370, 180, 210, 0, 2001); //CheckBox + mainQuestMenu.AddText(400, 180, wordHue, transparencyToggleText); + mainQuestMenu.AddBackground(365, 205, 280, 22, 9350); + mainQuestMenu.AddText(370, 205, 0, "Sort by Quest Category"); + // Add category selection buttons + for( var l = 0; l < categories.length; l++ ) + { + var categoryStartY = 230; // Adjusted start position for categories + var yPosition = categoryStartY + l * 25; // Spacing between categories + var isSelected = categories[l] == currentCategory; + mainQuestMenu.AddCheckbox( 370, yPosition, 210, 0, l + 1000 ); //CheckBox + //mainQuestMenu.AddButton( 370, yPosition, isSelected ? 1141 : 1143, isSelected ? 1141 : 1143, 1, 0, i + 1000 ); // Category buttons + mainQuestMenu.AddText( 400, yPosition, isSelected ? 500 : wordHue, categories[l] ); + } + + var showLoginQuests = playerSettings["ShowLoginQuestOffers"] != false; + var loginQuestToggleText = showLoginQuests ? "Disable Quest Prompt" : "Enable Quest Prompt"; + mainQuestMenu.AddCheckbox( 370, 450, 210, 0, 2002 ); // CheckBox for login quest toggle + mainQuestMenu.AddText( 400, 450, wordHue, loginQuestToggleText ); + + mainQuestMenu.AddButton( 380, 480, 238, 240, 1, 0, 2000 );//apple button + mainQuestMenu.AddButton( 550, 480, 247, 249, 1, 0, 1 );//okay button + + // Add a fallback for when no quests are found + if( filteredQuests.length == 0 ) + { + mainQuestMenu.AddText( 50, 250, 0, "No quests match the selected category." ); + } + + mainQuestMenu.Send( socket ); + mainQuestMenu.Free(); +} + +/** @type { ( pUser: Character ) => void } */ +function CompletedQuestsMenu( pUser ) +{ + if( !ValidateObject( pUser )) + return; + + var socket = pUser.socket; + if( socket == null ) + return; + + // Read completed quests + var playerSettings = TriggerEvent( 5800, "ReadPlayerSettings", pUser ) || {}; + var completedQuests = TriggerEvent( 5800, "ReadArchivedQuests", pUser ) || []; + var useTransparentBackground = playerSettings["UseTransparentBackground"] || false; + + var uniqueIds = {}; + var uniqueQuests = []; + for( var i = 0; i < completedQuests.length; i++ ) + { + var id = (typeof completedQuests[i] === "object") ? completedQuests[i].questID : completedQuests[i]; + if( !uniqueIds[id] ) + { + uniqueIds[id] = 1; uniqueQuests.push(id); + } + } + + var questsPerPage = 10; // Number of quests per page + var totalPages = Math.ceil( uniqueQuests.length / questsPerPage ); + + var completedQuestMenu = new Gump(); + completedQuestMenu.AddBackground( 30, 120, 296, 447, 1579 ); // Main background + var titleHue = 500; + // Background and transparency toggle + if( useTransparentBackground ) + { + completedQuestMenu.AddCheckerTrans( 30, 120, 296, 447 ); // Transparent background + var titleHue = 1160; + } + completedQuestMenu.AddGump( 70, 130, 1577 ); + completedQuestMenu.AddText( 120, 200, titleHue, "Completed Quests" ); + completedQuestMenu.AddButton( 50, 100, 1588, 1589, 1, 0, 20 ); // Active Quests Button + completedQuestMenu.AddText( 65, 102, 0, "Active Quests" ); + completedQuestMenu.AddButton( 180, 100, 1588, 1589, 1, 0, 21 ); // Completed Quests Button + completedQuestMenu.AddText( 193, 102, 500, "Completed Quests" ); + + // Loop through pages + for( var page = 0; page < totalPages; page++ ) + { + completedQuestMenu.AddPage( page + 1 ); + + var startIndex = page * questsPerPage; + var endIndex = Math.min( startIndex + questsPerPage, uniqueQuests.length ); + + for( var i = startIndex; i < endIndex; i++ ) + { + var questID = uniqueQuests[i]; + var quest = TriggerEvent( 5801, "QuestList", questID ); // Fetch quest details + + if( !quest ) + continue; + + var questIndex = i % questsPerPage; + var buttonY = 220 + questIndex * 30; + + // Add quest name and navigation button to details + completedQuestMenu.AddPageButton( 40, buttonY, 1141, 1143, totalPages + i + 1 ); + completedQuestMenu.AddText( 60, buttonY + 5, 0, quest.title + " ( Completed )" ); + } + + // Navigation buttons for the quest list pages + if( page > 0 ) + { + completedQuestMenu.AddPageButton( 50, 450, 1144, 1145, page ); + completedQuestMenu.AddText( 80, 455, 0, "Previous" ); + } + + if( page < totalPages - 1 ) + { + completedQuestMenu.AddPageButton( 200, 450, 1144, 1145, page + 2 ); + completedQuestMenu.AddText( 230, 455, 0, "Next" ); + } + } + + // Add quest details pages + for( var i = 0; i < uniqueQuests.length; i++ ) + { + var questID = uniqueQuests[i]; + var quest = TriggerEvent( 5801, "QuestList", questID ); + + if( !quest ) + continue; + + completedQuestMenu.AddPage( totalPages + i + 1 ); + + // Details background + completedQuestMenu.AddBackground( 350, 120, 306, 350, 1579 ); // Details background + completedQuestMenu.AddText( 420, 130, 500, quest.title ); + + // Add description + completedQuestMenu.AddHTMLGump( 370, 160, 266, 92, true, true, "Description:
" + ( quest.description || "No description available." ) ); + + // Add objectives + var objectives = "Completed"; + completedQuestMenu.AddHTMLGump( 370, 260, 266, 81, true, true, objectives ); + + // Add rewards + var rewards = TriggerEvent( 5802, "GetQuestRewards", quest ); // Use helper function to build rewards + completedQuestMenu.AddHTMLGump( 370, 350, 266, 100, true, true, rewards ); + + // Add a back button to return to the main quest list + var originalPage = Math.floor( i / questsPerPage ) + 1; + completedQuestMenu.AddPageButton( 50, 500, 1144, 1145, originalPage ); + } + + completedQuestMenu.Send( socket ); + completedQuestMenu.Free(); +} + +/** @type { ( myObj: Socket, pressed: number, gump: GumpData ) => void } */ +function onGumpPress( pSock, pButton, gumpData ) +{ + var pUser = pSock.currentChar; + if( !ValidateObject( pUser )) + return; + + var gumpID = 5823 + 0xffff; + + // Handle button presses for navigating between active and completed quests + if( pButton == 1 ) + { + pSock.CloseGump( gumpID, 0 ); + QuestMenu( pUser ); + } + else if( pButton == 20 ) + { + pSock.CloseGump( gumpID, 0 ); + QuestMenu( pUser ); + } + else if( pButton == 21 ) + { + pSock.CloseGump( gumpID, 0 ); + CompletedQuestsMenu( pUser ); + } + if( pButton == 2000 ) + { + var OtherButton = gumpData.getButton( 0 ); + // Handle category selection + if ( OtherButton >= 1000 && OtherButton <= 1006 ) + { // Assuming 1000-1003 are category buttons + var categories = ["All Quests", "Main Story Quest", "Side Quests", "Event Quests", "Daily Quests", "World Quests", "City Quests"]; + var selectedCategory = categories[OtherButton - 1000]; // Map button ID to category + + // Load existing settings + var playerSettings = TriggerEvent( 5800, "ReadPlayerSettings", pUser ) || {}; + + // Update the "QuestLogCategory" setting + playerSettings["QuestLogCategory"] = selectedCategory; + + // Save the updated settings object + TriggerEvent( 5800, "SavePlayerSettings", pUser, playerSettings ); + + pSock.SysMessage( "Quest log category set to: " + selectedCategory ); + + // Refresh the quest menu with the new category + pSock.CloseGump( gumpID, 0 ); + QuestMenu( pUser ); + } + else if( OtherButton == 2001 ) + { // Transparency toggle button + // Load existing settings + var playerSettings = TriggerEvent( 5800, "ReadPlayerSettings", pUser ) || {}; + + // Toggle the transparency setting + var useTransparentBackground = playerSettings["UseTransparentBackground"] || false; + playerSettings["UseTransparentBackground"] = !useTransparentBackground; + + // Save the updated settings + TriggerEvent( 5800, "SavePlayerSettings", pUser, playerSettings ); + + pSock.SysMessage( "Transparency " + ( playerSettings["UseTransparentBackground"] ? "enabled" : "disabled" ) + "." ); + + // Refresh the options menu + pSock.CloseGump( gumpID, 0 ); + QuestMenu( pUser ); + } + else if( OtherButton == 2002 ) + { + var playerSettings = TriggerEvent( 5800, "ReadPlayerSettings", pUser ) || {}; + var current = playerSettings["ShowLoginQuestOffers"] !== false; + playerSettings["ShowLoginQuestOffers"] = !current; + TriggerEvent( 5800, "SavePlayerSettings", pUser, playerSettings ); + + pSock.SysMessage( "Login Quest Prompts " + ( playerSettings["ShowLoginQuestOffers"] ? "enabled" : "disabled" ) + "." ); + + pSock.CloseGump( gumpID, 0 ); + QuestMenu( pUser ); + } + } +} diff --git a/data/js/server/quest/quest_seasonal_event.js b/data/js/server/quest/quest_seasonal_event.js new file mode 100644 index 000000000..9a603be7b --- /dev/null +++ b/data/js/server/quest/quest_seasonal_event.js @@ -0,0 +1,1758 @@ +/// +// @ts-check + +// ========================================================= +// Seasonal Event Quests (Full Replacement + Decorations) +// ========================================================= +// - Registers yearly holiday events with Event Manager (script 5) +// - Uses holiday packs so shard owners can enable only the cultures/themes they want +// - Supports built-in holidays + custom holidays +// - Spawns seasonal questgiver NPCs when an event starts +// - Removes them when the event ends +// - Loads/unloads seasonal decoration events through decorate.js +// - Supports GM commands to list/enable/disable packs and add/remove custom events +// +// Recommended folder: +// js/server/quests/seasonal/seasonal_event_quests.js +// +// Recommended uox.ini setting: +// EVENTMANAGERSYSTEM=1 +// +// Notes: +// - Built-in holidays are grouped into packs such as western, east_asian, islamic, etc. +// - Enabled packs are stored in a shared JSON file so admins do not have to edit script code +// - Custom events are independent and always loaded unless removed +// - Built-in movable holidays like Easter are computed automatically +// - Other movable holidays such as Ramadan, Eid, Diwali, Hanukkah, Lunar New Year, etc. +// are supported through optional per-year lookup tables in this script +// - Decorations are loaded/unloaded by calling helper functions in decorate.js +// ========================================================= + +const EVENT_MANAGER_SCRIPT_ID = 5; +const DECORATE_SCRIPT_ID = 1059; // decorate.js script ID + +// Shared files +const SEASONAL_CONFIG_FILE = "seasonal_config.json"; +const SEASONAL_CONFIG_SECTION = "Seasonal"; + +const SEASONAL_CUSTOM_EVENTS_FILE = "seasonal_custom_events.json"; +const SEASONAL_CUSTOM_EVENTS_SECTION = "SeasonalCustom"; + +const SEASONAL_ACTIVE_FILE = "seasonal_active.json"; +const SEASONAL_ACTIVE_SECTION = "SeasonalActive"; + +// Event Manager unique ID prefix +const SEASONAL_EVENT_PREFIX = "seasonal_"; + +// --------------------------------------------------------- +// Script Registration +// --------------------------------------------------------- +function CommandRegistration() +{ + RegisterCommand( "season", 8, true ); +} + +// --------------------------------------------------------- +// Startup +// --------------------------------------------------------- +function onScriptLoad() +{ + RegisterConfiguredSeasonalEvents(); + RestoreActiveSeasonalSpawns(); +} + +// ========================================================= +// CONFIG +// ========================================================= + +function GetDefaultSeasonalConfig() +{ + return { + enabledPacks: [ + "western", + "shard_fun" + ] + }; +} + +/** @type { () => any } */ +function LoadSeasonalConfig() +{ + var file = new UOXCFile(); + var defaultConfig = GetDefaultSeasonalConfig(); + + file.Open( SEASONAL_CONFIG_FILE, "r", SEASONAL_CONFIG_SECTION ); + if( file && file.Length() > 0 ) + { + var rawText = ""; + while( !file.EOF() ) + { + rawText += String( file.ReadUntil( "\n" ) ); + } + + file.Close(); + file.Free(); + + var parsedConfig = SafeParseJson( rawText ); + if( parsedConfig ) + { + if( !parsedConfig.enabledPacks || !IsArray( parsedConfig.enabledPacks ) ) + { + parsedConfig.enabledPacks = defaultConfig.enabledPacks.slice( 0 ); + } + + return parsedConfig; + } + } + + if( file ) + { + file.Free(); + } + + SaveSeasonalConfig( defaultConfig ); + return defaultConfig; +} + +/** @type { ( config: any ) => void } */ +function SaveSeasonalConfig( config ) +{ + var file = new UOXCFile(); + file.Open( SEASONAL_CONFIG_FILE, "w", SEASONAL_CONFIG_SECTION ); + if( file ) + { + file.Write( JSON.stringify( config || GetDefaultSeasonalConfig() ) + "\n" ); + file.Close(); + file.Free(); + } +} + +// ========================================================= +// BUILT-IN HOLIDAY CATALOG +// ========================================================= + +/** @type { () => any } */ +function GetBuiltInHolidayCatalog() +{ + return { + new_years_day: { + key: "new_years_day", + name: "New Years Day", + type: "FIXED", + month: 1, + day: 1, + hour: 0, + minute: 5, + durationHours: 48, + decorationEventName: "new_years_day", + spawns: [ + { npcSectionID: "dl_newyears_questgiver", x: 1455, y: 1220, z: 0, world: 0, instance: 0 } + ] + }, + + lunar_new_year: { + key: "lunar_new_year", + name: "Lunar New Year", + type: "LUNAR_NEWYEAR", + hour: 12, + minute: 0, + durationHours: 120, + decorationEventName: "lunar_new_year", + spawns: [ + { npcSectionID: "dl_lunarnewyear_questgiver", x: 1456, y: 1221, z: 0, world: 0, instance: 0 } + ] + }, + + valentines_day: { + key: "valentines_day", + name: "Valentines Day", + type: "FIXED", + month: 2, + day: 14, + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "valentines_day", + spawns: [ + { npcSectionID: "dl_valentines_questgiver", x: 1457, y: 1222, z: 0, world: 0, instance: 0 } + ] + }, + + st_patricks_day: { + key: "st_patricks_day", + name: "St. Patricks Day", + type: "FIXED", + month: 3, + day: 17, + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "st_patricks_day", + spawns: [ + { npcSectionID: "dl_stpatricks_questgiver", x: 1458, y: 1222, z: 0, world: 0, instance: 0 } + ] + }, + + holi: { + key: "holi", + name: "Holi", + type: "HOLI", + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "holi", + spawns: [ + { npcSectionID: "dl_holi_questgiver", x: 1458, y: 1223, z: 0, world: 0, instance: 0 } + ] + }, + + easter: { + key: "easter", + name: "Easter Festival", + type: "EASTER", + hour: 10, + minute: 0, + durationHours: 96, + decorationEventName: "easter", + spawns: [ + { npcSectionID: "dl_easter_questgiver", x: 1458, y: 1224, z: 0, world: 0, instance: 0 } + ] + }, + + ramadan: { + key: "ramadan", + name: "Ramadan", + type: "RAMADAN", + hour: 12, + minute: 0, + durationHours: 240, + decorationEventName: "ramadan", + spawns: [ + { npcSectionID: "dl_ramadan_questgiver", x: 1459, y: 1224, z: 0, world: 0, instance: 0 } + ] + }, + + eid_al_fitr: { + key: "eid_al_fitr", + name: "Eid al-Fitr", + type: "EID_AL_FITR", + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "eid_al_fitr", + spawns: [ + { npcSectionID: "dl_eidfitr_questgiver", x: 1460, y: 1224, z: 0, world: 0, instance: 0 } + ] + }, + + vesak: { + key: "vesak", + name: "Vesak", + type: "VESAK", + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "vesak", + spawns: [ + { npcSectionID: "dl_vesak_questgiver", x: 1461, y: 1224, z: 0, world: 0, instance: 0 } + ] + }, + + beltane: { + key: "beltane", + name: "Beltane / May Day", + type: "FIXED", + month: 5, + day: 1, + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "beltane", + spawns: [ + { npcSectionID: "dl_beltane_questgiver", x: 1459, y: 1223, z: 0, world: 0, instance: 0 } + ] + }, + + dragon_boat_festival: { + key: "dragon_boat_festival", + name: "Dragon Boat Festival", + type: "DRAGON_BOAT", + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "dragon_boat_festival", + spawns: [ + { npcSectionID: "dl_dragonboat_questgiver", x: 1460, y: 1223, z: 0, world: 0, instance: 0 } + ] + }, + + midsummer: { + key: "midsummer", + name: "Midsummer Festival", + type: "FIXED", + month: 6, + day: 21, + hour: 12, + minute: 0, + durationHours: 96, + decorationEventName: "midsummer", + spawns: [ + { npcSectionID: "dl_midsummer_questgiver", x: 1460, y: 1224, z: 0, world: 0, instance: 0 } + ] + }, + + july4: { + key: "july4", + name: "Founders Day / July 4th", + type: "FIXED", + month: 7, + day: 4, + hour: 12, + minute: 0, + durationHours: 72, + decorationEventName: "july4", + spawns: [ + { npcSectionID: "dl_july4_questgiver", x: 1462, y: 1218, z: 0, world: 0, instance: 0 } + ] + }, + + obon: { + key: "obon", + name: "Obon Festival", + type: "FIXED", + month: 8, + day: 15, + hour: 12, + minute: 0, + durationHours: 96, + decorationEventName: "obon", + spawns: [ + { npcSectionID: "dl_obon_questgiver", x: 1461, y: 1220, z: 0, world: 0, instance: 0 } + ] + }, + + mid_autumn_festival: { + key: "mid_autumn_festival", + name: "Mid-Autumn Festival", + type: "MID_AUTUMN", + hour: 12, + minute: 0, + durationHours: 96, + decorationEventName: "mid_autumn_festival", + spawns: [ + { npcSectionID: "dl_midautumn_questgiver", x: 1462, y: 1220, z: 0, world: 0, instance: 0 } + ] + }, + + pirates_day: { + key: "pirates_day", + name: "Talk Like a Pirate Day", + type: "FIXED", + month: 9, + day: 19, + hour: 12, + minute: 0, + durationHours: 48, + decorationEventName: "pirates_day", + spawns: [ + { npcSectionID: "dl_piratesday_questgiver", x: 1463, y: 1220, z: 0, world: 0, instance: 0 } + ] + }, + + harvest_festival: { + key: "harvest_festival", + name: "Harvest Festival", + type: "FIXED", + month: 9, + day: 22, + hour: 12, + minute: 0, + durationHours: 96, + decorationEventName: "harvest_festival", + spawns: [ + { npcSectionID: "dl_harvest_questgiver", x: 1464, y: 1220, z: 0, world: 0, instance: 0 } + ] + }, + + halloween: { + key: "halloween", + name: "Halloween (All Hallows)", + type: "FIXED", + month: 10, + day: 31, + hour: 12, + minute: 0, + durationHours: 168, + decorationEventName: "halloween", + spawns: [ + { npcSectionID: "dl_halloween_questgiver", x: 1460, y: 1225, z: 0, world: 0, instance: 0 } + ] + }, + + day_of_the_dead: { + key: "day_of_the_dead", + name: "Da de los Muertos", + type: "FIXED", + month: 11, + day: 2, + hour: 12, + minute: 0, + durationHours: 96, + decorationEventName: "day_of_the_dead", + spawns: [ + { npcSectionID: "dl_dayofthedead_questgiver", x: 1461, y: 1226, z: 0, world: 0, instance: 0 } + ] + }, + + bonfire_night: { + key: "bonfire_night", + name: "Bonfire Night", + type: "FIXED", + month: 11, + day: 5, + hour: 12, + minute: 0, + durationHours: 48, + decorationEventName: "bonfire_night", + spawns: [ + { npcSectionID: "dl_bonfire_questgiver", x: 1462, y: 1226, z: 0, world: 0, instance: 0 } + ] + }, + + diwali: { + key: "diwali", + name: "Diwali", + type: "DIWALI", + hour: 12, + minute: 0, + durationHours: 120, + decorationEventName: "diwali", + spawns: [ + { npcSectionID: "dl_diwali_questgiver", x: 1453, y: 1219, z: 0, world: 0, instance: 0 } + ] + }, + + hanukkah: { + key: "hanukkah", + name: "Hanukkah", + type: "HANUKKAH", + hour: 12, + minute: 0, + durationHours: 192, + decorationEventName: "hanukkah", + spawns: [ + { npcSectionID: "dl_hanukkah_questgiver", x: 1454, y: 1217, z: 0, world: 0, instance: 0 } + ] + }, + + winter_solstice: { + key: "winter_solstice", + name: "Winter Solstice", + type: "FIXED", + month: 12, + day: 21, + hour: 12, + minute: 0, + durationHours: 120, + decorationEventName: "winter_solstice", + spawns: [ + { npcSectionID: "dl_wintersolstice_questgiver", x: 1455, y: 1217, z: 0, world: 0, instance: 0 } + ] + }, + + christmas: { + key: "christmas", + name: "Christmas / Yuletide", + type: "FIXED", + month: 12, + day: 25, + hour: 12, + minute: 0, + durationHours: 216, + decorationEventName: "christmas", + spawns: [ + { npcSectionID: "dl_christmas_questgiver", x: 1452, y: 1216, z: 0, world: 0, instance: 0 } + ] + }, + + new_years_eve: { + key: "new_years_eve", + name: "New Years Eve", + type: "FIXED", + month: 12, + day: 31, + hour: 18, + minute: 0, + durationHours: 18, + decorationEventName: "new_years_eve", + spawns: [ + { npcSectionID: "dl_newyearseve_questgiver", x: 1458, y: 1222, z: 0, world: 0, instance: 0 } + ] + } + }; +} + +// ========================================================= +// BUILT-IN HOLIDAY PACKS +// ========================================================= + +/** @type { () => any } */ +function GetBuiltInHolidayPacks() +{ + return { + western: [ + "new_years_day", + "valentines_day", + "easter", + "halloween", + "christmas", + "new_years_eve" + ], + + celtic_pagan: [ + "beltane", + "midsummer", + "winter_solstice" + ], + + east_asian: [ + "lunar_new_year", + "dragon_boat_festival", + "obon", + "mid_autumn_festival" + ], + + south_asian: [ + "holi", + "diwali", + "vesak" + ], + + jewish: [ + "hanukkah" + ], + + islamic: [ + "ramadan", + "eid_al_fitr" + ], + + latin_american: [ + "day_of_the_dead" + ], + + irish: [ + "st_patricks_day" + ], + + american: [ + "july4" + ], + + british: [ + "bonfire_night" + ], + + shard_fun: [ + "july4", + "pirates_day", + "harvest_festival" + ], + + all: [ + "new_years_day", + "lunar_new_year", + "valentines_day", + "st_patricks_day", + "holi", + "easter", + "ramadan", + "eid_al_fitr", + "vesak", + "beltane", + "dragon_boat_festival", + "midsummer", + "july4", + "obon", + "mid_autumn_festival", + "pirates_day", + "harvest_festival", + "halloween", + "day_of_the_dead", + "bonfire_night", + "diwali", + "hanukkah", + "winter_solstice", + "christmas", + "new_years_eve" + ] + }; +} + +/** @type { () => any[] } */ +function GetEnabledBuiltInHolidayDefinitions() +{ + var seasonalConfig = LoadSeasonalConfig(); + var enabledPackNames = seasonalConfig.enabledPacks || []; + var holidayCatalog = GetBuiltInHolidayCatalog(); + var holidayPacks = GetBuiltInHolidayPacks(); + + var seenHolidayKeys = {}; + var enabledHolidayDefinitions = []; + + for( var packIndex = 0; packIndex < enabledPackNames.length; packIndex++ ) + { + var packName = String( enabledPackNames[packIndex] || "" ).toLowerCase(); + var holidayKeyList = holidayPacks[packName]; + + if( !holidayKeyList || !holidayKeyList.length ) + { + continue; + } + + for( var holidayIndex = 0; holidayIndex < holidayKeyList.length; holidayIndex++ ) + { + var holidayKey = String( holidayKeyList[holidayIndex] || "" ).toLowerCase(); + + if( seenHolidayKeys[holidayKey] ) + { + continue; + } + + if( holidayCatalog[holidayKey] ) + { + seenHolidayKeys[holidayKey] = true; + enabledHolidayDefinitions.push( holidayCatalog[holidayKey] ); + } + } + } + + return enabledHolidayDefinitions; +} + +// ========================================================= +// CUSTOM EVENT STORAGE +// ========================================================= + +/** @type { () => any[] } */ +function LoadCustomSeasonalEvents() +{ + var file = new UOXCFile(); + var customEvents = []; + + file.Open( SEASONAL_CUSTOM_EVENTS_FILE, "r", SEASONAL_CUSTOM_EVENTS_SECTION ); + if( file && file.Length() > 0 ) + { + var rawText = ""; + while( !file.EOF() ) + { + rawText += String( file.ReadUntil( "\n" ) ); + } + + file.Close(); + file.Free(); + + var parsedData = SafeParseJson( rawText ); + if( parsedData && parsedData.events && IsArray( parsedData.events ) ) + { + return parsedData.events; + } + } + + if( file ) + { + file.Free(); + } + + return customEvents; +} + +/** @type { ( data: any ) => void } */ +function SaveCustomSeasonalEvents( data ) +{ + var file = new UOXCFile(); + file.Open( SEASONAL_CUSTOM_EVENTS_FILE, "w", SEASONAL_CUSTOM_EVENTS_SECTION ); + if( file ) + { + file.Write( JSON.stringify( data || { events: [] } ) + "\n" ); + file.Close(); + file.Free(); + } +} + +/** @type { ( eventDefinition: any ) => void } */ +function UpsertCustomSeasonalEvent( eventDefinition ) +{ + var data = { events: LoadCustomSeasonalEvents() }; + var targetKey = String( eventDefinition.key || "" ).toLowerCase(); + var foundExisting = false; + + for( var eventIndex = 0; eventIndex < data.events.length; eventIndex++ ) + { + if( String( data.events[eventIndex].key || "" ).toLowerCase() == targetKey ) + { + data.events[eventIndex] = eventDefinition; + foundExisting = true; + break; + } + } + + if( !foundExisting ) + { + data.events.push( eventDefinition ); + } + + SaveCustomSeasonalEvents( data ); +} + +/** @type { ( eventKey: string ) => boolean } */ +function RemoveCustomSeasonalEvent( eventKey ) +{ + var data = { events: LoadCustomSeasonalEvents() }; + var normalizedEventKey = String( eventKey || "" ).toLowerCase(); + + var filteredEvents = []; + var removed = false; + + for( var eventIndex = 0; eventIndex < data.events.length; eventIndex++ ) + { + if( String( data.events[eventIndex].key || "" ).toLowerCase() == normalizedEventKey ) + { + removed = true; + continue; + } + + filteredEvents.push( data.events[eventIndex] ); + } + + if( removed ) + { + SaveCustomSeasonalEvents( { events: filteredEvents } ); + } + + return removed; +} + +// ========================================================= +// ACTIVE STATE STORAGE +// ========================================================= + +/** @type { () => any } */ +function LoadActiveSeasonalState() +{ + var file = new UOXCFile(); + var defaultState = { active: {}, spawns: {} }; + + file.Open( SEASONAL_ACTIVE_FILE, "r", SEASONAL_ACTIVE_SECTION ); + if( file && file.Length() > 0 ) + { + var rawText = ""; + while( !file.EOF() ) + { + rawText += String( file.ReadUntil( "\n" ) ); + } + + file.Close(); + file.Free(); + + var parsedState = SafeParseJson( rawText ); + if( parsedState ) + { + return parsedState; + } + } + + if( file ) + { + file.Free(); + } + + return defaultState; +} + +/** @type { ( state: any ) => void } */ +function SaveActiveSeasonalState( state ) +{ + var file = new UOXCFile(); + file.Open( SEASONAL_ACTIVE_FILE, "w", SEASONAL_ACTIVE_SECTION ); + if( file ) + { + file.Write( JSON.stringify( state || { active: {}, spawns: {} } ) + "\n" ); + file.Close(); + file.Free(); + } +} + +/** @type { ( holidayKey: string, isActive: boolean ) => void } */ +function MarkSeasonalActive( holidayKey, isActive ) +{ + var activeState = LoadActiveSeasonalState(); + var normalizedHolidayKey = String( holidayKey || "" ).toLowerCase(); + + if( !activeState.active ) + { + activeState.active = {}; + } + + activeState.active[normalizedHolidayKey] = ( isActive ? 1 : 0 ); + SaveActiveSeasonalState( activeState ); +} + +// ========================================================= +// REGISTRATION / RELOAD +// ========================================================= + +function RegisterConfiguredSeasonalEvents() +{ + var enabledBuiltInEvents = GetEnabledBuiltInHolidayDefinitions(); + for( var builtInIndex = 0; builtInIndex < enabledBuiltInEvents.length; builtInIndex++ ) + { + RegisterOrRescheduleHoliday( enabledBuiltInEvents[builtInIndex] ); + } + + var customEvents = LoadCustomSeasonalEvents(); + for( var customIndex = 0; customIndex < customEvents.length; customIndex++ ) + { + RegisterOrRescheduleHoliday( customEvents[customIndex] ); + } +} + +/** @type { ( holidayDefinition: any ) => void } */ +function RegisterOrRescheduleHoliday( holidayDefinition ) +{ + if( !holidayDefinition || !holidayDefinition.key ) + { + return; + } + + var nowMs = Date.now(); + var nextStartMs = ComputeNextHolidayStartMs( holidayDefinition, nowMs ); + if( nextStartMs <= 0 ) + { + return; + } + + var durationMs = Math.max( 0, ToIntSafe( holidayDefinition.durationHours ) * 3600 * 1000 ); + var uniqueEventID = BuildUniqueSeasonalEventID( holidayDefinition.key, nextStartMs ); + + TriggerEvent( + EVENT_MANAGER_SCRIPT_ID, + "RegisterEvent", + { + uniqueEventID: uniqueEventID, + callbackScriptID: SCRIPT.script_id, + callbackFuncStart: "SeasonalEventStart", + callbackFuncEnd: "SeasonalEventEnd", + type: "ONCE", + date: nextStartMs, + duration: durationMs, + name: holidayDefinition.name || ( "Seasonal: " + holidayDefinition.key ), + desc: "Seasonal quest window: " + holidayDefinition.key + }, + { + holidayKey: holidayDefinition.key, + holidayName: holidayDefinition.name || holidayDefinition.key, + holidayDefinition: holidayDefinition + }, + { + holidayKey: holidayDefinition.key, + holidayName: holidayDefinition.name || holidayDefinition.key, + holidayDefinition: holidayDefinition + } + ); +} + +/** @type { ( holidayKey: string, startMs: number ) => string } */ +function BuildUniqueSeasonalEventID( holidayKey, startMs ) +{ + return SEASONAL_EVENT_PREFIX + String( holidayKey || "" ).toLowerCase() + "_" + String( startMs ); +} + +// ========================================================= +// EVENT MANAGER CALLBACKS +// ========================================================= + +function SeasonalEventStart( args ) +{ + if( !args || !args.holidayDefinition ) + { + return; + } + + var holidayKey = String( args.holidayKey || "" ).toLowerCase(); + var holidayName = String( args.holidayName || holidayKey ); + + MarkSeasonalActive( holidayKey, true ); + BroadcastMessage( "[Seasonal] " + holidayName + " is now active!" ); + + SpawnSeasonalQuestGivers( args.holidayDefinition ); + ActivateSeasonalDecorations( args.holidayDefinition ); +} + +function SeasonalEventEnd( args ) +{ + if( !args || !args.holidayDefinition ) + { + return; + } + + var holidayKey = String( args.holidayKey || "" ).toLowerCase(); + var holidayName = String( args.holidayName || holidayKey ); + + BroadcastMessage( "[Seasonal] " + holidayName + " has ended." ); + + DespawnSeasonalQuestGivers( args.holidayDefinition ); + DeactivateSeasonalDecorations( args.holidayDefinition ); + MarkSeasonalActive( holidayKey, false ); + + RegisterOrRescheduleHoliday( args.holidayDefinition ); +} + +// ========================================================= +// DECORATION EVENT SUPPORT +// ========================================================= + +/** @type { ( holidayDefinition: any ) => void } */ +function ActivateSeasonalDecorations( holidayDefinition ) +{ + var decorationEventNames = GetDecorationEventNamesFromHoliday( holidayDefinition ); + if( decorationEventNames.length < 1 ) + { + return; + } + + for( var eventIndex = 0; eventIndex < decorationEventNames.length; eventIndex++ ) + { + var decorationEventName = decorationEventNames[eventIndex]; + if( decorationEventName == "" ) + { + continue; + } + + TriggerEvent( DECORATE_SCRIPT_ID, "SeasonalLoadEventDecorations", decorationEventName ); + } +} + +/** @type { ( holidayDefinition: any ) => void } */ +function DeactivateSeasonalDecorations( holidayDefinition ) +{ + var decorationEventNames = GetDecorationEventNamesFromHoliday( holidayDefinition ); + if( decorationEventNames.length < 1 ) + { + return; + } + + for( var eventIndex = 0; eventIndex < decorationEventNames.length; eventIndex++ ) + { + var decorationEventName = decorationEventNames[eventIndex]; + if( decorationEventName == "" ) + { + continue; + } + + TriggerEvent( DECORATE_SCRIPT_ID, "SeasonalUnloadEventDecorations", decorationEventName ); + } +} + +/** @type { ( holidayDefinition: any ) => string[] } */ +function GetDecorationEventNamesFromHoliday( holidayDefinition ) +{ + var decorationEventNames = []; + + if( !holidayDefinition ) + { + return decorationEventNames; + } + + if( holidayDefinition.decorationEventNames && IsArray( holidayDefinition.decorationEventNames ) ) + { + for( var index = 0; index < holidayDefinition.decorationEventNames.length; index++ ) + { + var decorationEventName = String( holidayDefinition.decorationEventNames[index] || "" ).toLowerCase(); + if( decorationEventName != "" ) + { + decorationEventNames.push( decorationEventName ); + } + } + } + + if( decorationEventNames.length < 1 && holidayDefinition.decorationEventName ) + { + var singleDecorationEventName = String( holidayDefinition.decorationEventName || "" ).toLowerCase(); + if( singleDecorationEventName != "" ) + { + decorationEventNames.push( singleDecorationEventName ); + } + } + + return decorationEventNames; +} + +// ========================================================= +// SPAWN / DESPAWN +// ========================================================= + +/** @type { ( holidayDefinition: any ) => void } */ +function SpawnSeasonalQuestGivers( holidayDefinition ) +{ + if( !holidayDefinition || !holidayDefinition.spawns || !holidayDefinition.spawns.length ) + { + return; + } + + var holidayKey = String( holidayDefinition.key || "" ).toLowerCase(); + var activeState = LoadActiveSeasonalState(); + + if( !activeState.spawns ) + { + activeState.spawns = {}; + } + + if( !activeState.spawns[holidayKey] ) + { + activeState.spawns[holidayKey] = []; + } + + for( var spawnIndex = 0; spawnIndex < holidayDefinition.spawns.length; spawnIndex++ ) + { + var spawnInfo = holidayDefinition.spawns[spawnIndex]; + if( !spawnInfo || !spawnInfo.npcSectionID ) + { + continue; + } + + var spawnedNpc = SpawnNPC( + String( spawnInfo.npcSectionID ), + ToIntSafe( spawnInfo.x ), + ToIntSafe( spawnInfo.y ), + ToIntSafe( spawnInfo.z ), + ToIntSafe( spawnInfo.world ), + ToIntSafe( spawnInfo.instance ) + ); + + if( ValidateObject( spawnedNpc ) ) + { + spawnedNpc.SetTag( "SeasonalEventKey", holidayKey ); + spawnedNpc.SetTag( "SeasonalQuestGiver", 1 ); + + activeState.spawns[holidayKey].push( spawnedNpc.serial ); + } + } + + SaveActiveSeasonalState( activeState ); +} + +/** @type { ( holidayDefinition: any ) => void } */ +function DespawnSeasonalQuestGivers( holidayDefinition ) +{ + if( !holidayDefinition ) + { + return; + } + + var holidayKey = String( holidayDefinition.key || "" ).toLowerCase(); + var activeState = LoadActiveSeasonalState(); + + if( !activeState.spawns || !activeState.spawns[holidayKey] ) + { + return; + } + + var spawnedSerials = activeState.spawns[holidayKey]; + for( var serialIndex = 0; serialIndex < spawnedSerials.length; serialIndex++ ) + { + var spawnedNpc = CalcCharFromSer( ToIntSafe( spawnedSerials[serialIndex] ) ); + if( ValidateObject( spawnedNpc ) ) + { + if( spawnedNpc.GetTag( "SeasonalQuestGiver" ) ) + { + spawnedNpc.Delete(); + } + } + } + + delete activeState.spawns[holidayKey]; + SaveActiveSeasonalState( activeState ); +} + +function RestoreActiveSeasonalSpawns() +{ + var activeState = LoadActiveSeasonalState(); + if( !activeState || !activeState.active ) + { + return; + } + + var allHolidayDefinitions = GetEnabledBuiltInHolidayDefinitions().concat( LoadCustomSeasonalEvents() ); + + for( var holidayIndex = 0; holidayIndex < allHolidayDefinitions.length; holidayIndex++ ) + { + var holidayDefinition = allHolidayDefinitions[holidayIndex]; + if( !holidayDefinition || !holidayDefinition.key ) + { + continue; + } + + var holidayKey = String( holidayDefinition.key || "" ).toLowerCase(); + + if( activeState.active[holidayKey] == 1 ) + { + if( !activeState.spawns || !activeState.spawns[holidayKey] || !activeState.spawns[holidayKey].length ) + { + SpawnSeasonalQuestGivers( holidayDefinition ); + } + } + } +} + +// ========================================================= +// GM COMMANDS +// ========================================================= + +/** @type { ( socket: Socket, cmdString: string ) => void } */ +function command_SEASON( socket, cmdString ) +{ + var gmChar = socket.currentChar; + if( !ValidateObject( gmChar ) ) + { + return; + } + + cmdString = String( cmdString || "" ).replace( /^\s+|\s+$/g, "" ); + var parts = ( cmdString.length > 0 ? cmdString.split( /\s+/ ) : [] ); + + if( parts.length < 1 ) + { + PrintSeasonHelp( socket ); + return; + } + + var subCommand = String( parts[0] || "" ).toLowerCase(); + + if( subCommand == "list" ) + { + ListEnabledSeasonalEvents( socket ); + return; + } + + if( subCommand == "catalog" ) + { + ListHolidayCatalog( socket ); + return; + } + + if( subCommand == "packs" ) + { + ListHolidayPacks( socket ); + return; + } + + if( subCommand == "active" ) + { + ListActiveSeasonalEvents( socket ); + return; + } + + if( subCommand == "enablepack" ) + { + if( parts.length < 2 ) + { + socket.SysMessage( "Usage: [season enablepack " ); + return; + } + + EnableHolidayPack( socket, parts[1] ); + return; + } + + if( subCommand == "disablepack" ) + { + if( parts.length < 2 ) + { + socket.SysMessage( "Usage: [season disablepack " ); + return; + } + + DisableHolidayPack( socket, parts[1] ); + return; + } + + if( subCommand == "reload" ) + { + RegisterConfiguredSeasonalEvents(); + socket.SysMessage( "Seasonal events re-registered from current config." ); + return; + } + + // [season add [decorationEventName] + if( subCommand == "add" ) + { + if( parts.length < 13 ) + { + socket.SysMessage( "Usage: [season add [decorationEventName]" ); + return; + } + + var eventKey = String( parts[1] || "" ).toLowerCase(); + var month = parseInt( parts[2], 10 ); + var day = parseInt( parts[3], 10 ); + var hour = parseInt( parts[4], 10 ); + var minute = parseInt( parts[5], 10 ); + var durationHours = parseInt( parts[6], 10 ); + var npcSectionID = String( parts[7] || "" ); + var x = parseInt( parts[8], 10 ); + var y = parseInt( parts[9], 10 ); + var z = parseInt( parts[10], 10 ); + var world = parseInt( parts[11], 10 ); + var instance = parseInt( parts[12], 10 ); + var decorationEventName = ""; + + if( parts.length >= 14 ) + { + decorationEventName = String( parts[13] || "" ).toLowerCase(); + } + + if( + isNaN( month ) || isNaN( day ) || isNaN( hour ) || isNaN( minute ) || + isNaN( durationHours ) || isNaN( x ) || isNaN( y ) || isNaN( z ) || + isNaN( world ) || isNaN( instance ) + ) + { + socket.SysMessage( "Invalid numeric values." ); + return; + } + + var customEventDefinition = { + key: eventKey, + name: "Custom Seasonal Event: " + eventKey, + type: "FIXED", + month: month, + day: day, + hour: hour, + minute: minute, + durationHours: durationHours, + spawns: [ + { + npcSectionID: npcSectionID, + x: x, + y: y, + z: z, + world: world, + instance: instance + } + ] + }; + + if( decorationEventName != "" ) + { + customEventDefinition.decorationEventName = decorationEventName; + } + + UpsertCustomSeasonalEvent( customEventDefinition ); + RegisterOrRescheduleHoliday( customEventDefinition ); + + socket.SysMessage( "Added custom seasonal event '" + eventKey + "' and registered it." ); + return; + } + + if( subCommand == "remove" ) + { + if( parts.length < 2 ) + { + socket.SysMessage( "Usage: [season remove " ); + return; + } + + var removeKey = String( parts[1] || "" ).toLowerCase(); + if( RemoveCustomSeasonalEvent( removeKey ) ) + { + socket.SysMessage( "Removed custom seasonal event '" + removeKey + "' from config." ); + socket.SysMessage( "If it is already in Event Manager, use [em list and [em remove ." ); + } + else + { + socket.SysMessage( "No custom seasonal event found for '" + removeKey + "'." ); + } + return; + } + + PrintSeasonHelp( socket ); +} + +function PrintSeasonHelp( socket ) +{ + socket.SysMessage( "Seasonal Quest Commands:" ); + socket.SysMessage( " [season list" ); + socket.SysMessage( " [season catalog" ); + socket.SysMessage( " [season packs" ); + socket.SysMessage( " [season active" ); + socket.SysMessage( " [season enablepack " ); + socket.SysMessage( " [season disablepack " ); + socket.SysMessage( " [season reload" ); + socket.SysMessage( " [season add [decorationEventName]" ); + socket.SysMessage( " [season remove " ); +} + +/** @type { ( socket: Socket ) => void } */ +function ListHolidayCatalog( socket ) +{ + var holidayCatalog = GetBuiltInHolidayCatalog(); + socket.SysMessage( "--- Built-In Holiday Catalog ---" ); + + for( var holidayKey in holidayCatalog ) + { + if( holidayCatalog.hasOwnProperty( holidayKey ) ) + { + var holidayDefinition = holidayCatalog[holidayKey]; + var decorationInfo = ""; + + if( holidayDefinition.decorationEventName ) + { + decorationInfo = " | decor=" + holidayDefinition.decorationEventName; + } + else if( holidayDefinition.decorationEventNames && holidayDefinition.decorationEventNames.length ) + { + decorationInfo = " | decor=" + holidayDefinition.decorationEventNames.join( "," ); + } + + socket.SysMessage( holidayKey + " (" + holidayDefinition.name + ")" + decorationInfo ); + } + } +} + +/** @type { ( socket: Socket ) => void } */ +function ListHolidayPacks( socket ) +{ + var holidayPacks = GetBuiltInHolidayPacks(); + var seasonalConfig = LoadSeasonalConfig(); + var enabledPackLookup = {}; + + for( var packIndex = 0; packIndex < seasonalConfig.enabledPacks.length; packIndex++ ) + { + enabledPackLookup[String( seasonalConfig.enabledPacks[packIndex] || "" ).toLowerCase()] = true; + } + + socket.SysMessage( "--- Holiday Packs ---" ); + for( var packName in holidayPacks ) + { + if( holidayPacks.hasOwnProperty( packName ) ) + { + socket.SysMessage( + packName + + ( enabledPackLookup[String( packName ).toLowerCase()] ? " [ENABLED]" : " [DISABLED]" ) + + " - " + holidayPacks[packName].length + " holiday(s)" + ); + } + } +} + +/** @type { ( socket: Socket ) => void } */ +function ListEnabledSeasonalEvents( socket ) +{ + var builtInEvents = GetEnabledBuiltInHolidayDefinitions(); + var customEvents = LoadCustomSeasonalEvents(); + + socket.SysMessage( "--- Enabled Built-In Seasonal Events ---" ); + if( !builtInEvents.length ) + { + socket.SysMessage( "(none)" ); + } + else + { + for( var builtInIndex = 0; builtInIndex < builtInEvents.length; builtInIndex++ ) + { + var builtInDecorationInfo = ""; + if( builtInEvents[builtInIndex].decorationEventName ) + { + builtInDecorationInfo = " | decor=" + builtInEvents[builtInIndex].decorationEventName; + } + socket.SysMessage( builtInEvents[builtInIndex].key + " (" + builtInEvents[builtInIndex].name + ")" + builtInDecorationInfo ); + } + } + + socket.SysMessage( "--- Custom Seasonal Events ---" ); + if( !customEvents.length ) + { + socket.SysMessage( "(none)" ); + } + else + { + for( var customIndex = 0; customIndex < customEvents.length; customIndex++ ) + { + var customDecorationInfo = ""; + if( customEvents[customIndex].decorationEventName ) + { + customDecorationInfo = " | decor=" + customEvents[customIndex].decorationEventName; + } + socket.SysMessage( customEvents[customIndex].key + " (" + ( customEvents[customIndex].name || "custom" ) + ")" + customDecorationInfo ); + } + } +} + +/** @type { ( socket: Socket ) => void } */ +function ListActiveSeasonalEvents( socket ) +{ + var activeState = LoadActiveSeasonalState(); + + socket.SysMessage( "--- Active Seasonal Events ---" ); + if( !activeState.active ) + { + socket.SysMessage( "(none)" ); + return; + } + + var foundAny = false; + for( var holidayKey in activeState.active ) + { + if( activeState.active.hasOwnProperty( holidayKey ) && activeState.active[holidayKey] == 1 ) + { + foundAny = true; + socket.SysMessage( "ACTIVE: " + holidayKey ); + } + } + + if( !foundAny ) + { + socket.SysMessage( "(none)" ); + } +} + +/** @type { ( socket: Socket, packName: string ) => void } */ +function EnableHolidayPack( socket, packName ) +{ + var normalizedPackName = String( packName || "" ).toLowerCase(); + var holidayPacks = GetBuiltInHolidayPacks(); + + if( !holidayPacks[normalizedPackName] ) + { + socket.SysMessage( "Unknown pack: " + normalizedPackName ); + return; + } + + var seasonalConfig = LoadSeasonalConfig(); + var alreadyEnabled = false; + + for( var index = 0; index < seasonalConfig.enabledPacks.length; index++ ) + { + if( String( seasonalConfig.enabledPacks[index] || "" ).toLowerCase() == normalizedPackName ) + { + alreadyEnabled = true; + break; + } + } + + if( alreadyEnabled ) + { + socket.SysMessage( "Pack already enabled: " + normalizedPackName ); + return; + } + + seasonalConfig.enabledPacks.push( normalizedPackName ); + SaveSeasonalConfig( seasonalConfig ); + + socket.SysMessage( "Enabled holiday pack: " + normalizedPackName ); + socket.SysMessage( "Use [season reload to register newly enabled events immediately." ); +} + +/** @type { ( socket: Socket, packName: string ) => void } */ +function DisableHolidayPack( socket, packName ) +{ + var normalizedPackName = String( packName || "" ).toLowerCase(); + var seasonalConfig = LoadSeasonalConfig(); + + var filteredPacks = []; + var removed = false; + + for( var index = 0; index < seasonalConfig.enabledPacks.length; index++ ) + { + var existingPackName = String( seasonalConfig.enabledPacks[index] || "" ).toLowerCase(); + if( existingPackName == normalizedPackName ) + { + removed = true; + continue; + } + filteredPacks.push( existingPackName ); + } + + if( !removed ) + { + socket.SysMessage( "Pack was not enabled: " + normalizedPackName ); + return; + } + + seasonalConfig.enabledPacks = filteredPacks; + SaveSeasonalConfig( seasonalConfig ); + + socket.SysMessage( "Disabled holiday pack: " + normalizedPackName ); + socket.SysMessage( "Use [em list / [em remove if you also want already-registered Event Manager entries removed now." ); +} + +// ========================================================= +// DATE CALCULATIONS +// ========================================================= + +/** @type { ( holidayDefinition: any, nowMs: number ) => number } */ +function ComputeNextHolidayStartMs( holidayDefinition, nowMs ) +{ + var currentDate = new Date( nowMs ); + var targetYear = currentDate.getFullYear(); + var holidayStartDate = ResolveHolidayDateForYear( holidayDefinition, targetYear ); + + if( !holidayStartDate ) + { + return 0; + } + + if( holidayStartDate.getTime() <= nowMs ) + { + targetYear = targetYear + 1; + holidayStartDate = ResolveHolidayDateForYear( holidayDefinition, targetYear ); + } + + if( !holidayStartDate ) + { + return 0; + } + + return holidayStartDate.getTime(); +} + +/** @type { ( holidayDefinition: any, year: number ) => Date | null } */ +function ResolveHolidayDateForYear( holidayDefinition, year ) +{ + if( !holidayDefinition ) + { + return null; + } + + var holidayType = String( holidayDefinition.type || "FIXED" ).toUpperCase(); + var hour = ToIntSafe( holidayDefinition.hour ); + var minute = ToIntSafe( holidayDefinition.minute ); + + if( holidayType == "FIXED" ) + { + return new Date( + year, + ToIntSafe( holidayDefinition.month ) - 1, + ToIntSafe( holidayDefinition.day ), + hour, + minute, + 0, + 0 + ); + } + + if( holidayType == "EASTER" ) + { + var easterInfo = ComputeWesternEasterDate( year ); + return new Date( year, easterInfo.monthIndex, easterInfo.day, hour, minute, 0, 0 ); + } + + if( holidayType == "LUNAR_NEWYEAR" ) + { + return ResolveMappedHolidayDate( "lunar_new_year", year, hour, minute ); + } + + if( holidayType == "HOLI" ) + { + return ResolveMappedHolidayDate( "holi", year, hour, minute ); + } + + if( holidayType == "RAMADAN" ) + { + return ResolveMappedHolidayDate( "ramadan", year, hour, minute ); + } + + if( holidayType == "EID_AL_FITR" ) + { + return ResolveMappedHolidayDate( "eid_al_fitr", year, hour, minute ); + } + + if( holidayType == "VESAK" ) + { + return ResolveMappedHolidayDate( "vesak", year, hour, minute ); + } + + if( holidayType == "DRAGON_BOAT" ) + { + return ResolveMappedHolidayDate( "dragon_boat_festival", year, hour, minute ); + } + + if( holidayType == "MID_AUTUMN" ) + { + return ResolveMappedHolidayDate( "mid_autumn_festival", year, hour, minute ); + } + + if( holidayType == "DIWALI" ) + { + return ResolveMappedHolidayDate( "diwali", year, hour, minute ); + } + + if( holidayType == "HANUKKAH" ) + { + return ResolveMappedHolidayDate( "hanukkah", year, hour, minute ); + } + + return null; +} + +/** @type { ( holidayKey: string, year: number, hour: number, minute: number ) => Date | null } */ +function ResolveMappedHolidayDate( holidayKey, year, hour, minute ) +{ + var holidayDateTable = GetHolidayDateTable(); + var holidayMap = holidayDateTable[String( holidayKey || "" ).toLowerCase()]; + + if( !holidayMap || !holidayMap[year] ) + { + return null; + } + + var dateInfo = holidayMap[year]; + return new Date( year, dateInfo.month - 1, dateInfo.day, hour, minute, 0, 0 ); +} + +function GetHolidayDateTable() +{ + return { + lunar_new_year: { + 2025: { month: 1, day: 29 }, + 2026: { month: 2, day: 17 }, + 2027: { month: 2, day: 6 }, + 2028: { month: 1, day: 26 }, + 2029: { month: 2, day: 13 }, + 2030: { month: 2, day: 3 } + }, + + holi: { + 2025: { month: 3, day: 14 }, + 2026: { month: 3, day: 3 }, + 2027: { month: 3, day: 22 }, + 2028: { month: 3, day: 11 }, + 2029: { month: 3, day: 1 }, + 2030: { month: 3, day: 19 } + }, + + ramadan: { + 2025: { month: 3, day: 1 }, + 2026: { month: 2, day: 18 }, + 2027: { month: 2, day: 8 }, + 2028: { month: 1, day: 28 }, + 2029: { month: 1, day: 16 }, + 2030: { month: 1, day: 6 } + }, + + eid_al_fitr: { + 2025: { month: 3, day: 30 }, + 2026: { month: 3, day: 20 }, + 2027: { month: 3, day: 9 }, + 2028: { month: 2, day: 26 }, + 2029: { month: 2, day: 14 }, + 2030: { month: 2, day: 4 } + }, + + vesak: { + 2025: { month: 5, day: 12 }, + 2026: { month: 5, day: 31 }, + 2027: { month: 5, day: 20 }, + 2028: { month: 5, day: 9 }, + 2029: { month: 5, day: 27 }, + 2030: { month: 5, day: 16 } + }, + + dragon_boat_festival: { + 2025: { month: 5, day: 31 }, + 2026: { month: 6, day: 19 }, + 2027: { month: 6, day: 9 }, + 2028: { month: 5, day: 28 }, + 2029: { month: 6, day: 16 }, + 2030: { month: 6, day: 5 } + }, + + mid_autumn_festival: { + 2025: { month: 10, day: 6 }, + 2026: { month: 9, day: 25 }, + 2027: { month: 9, day: 15 }, + 2028: { month: 10, day: 3 }, + 2029: { month: 9, day: 22 }, + 2030: { month: 9, day: 12 } + }, + + diwali: { + 2025: { month: 10, day: 20 }, + 2026: { month: 11, day: 8 }, + 2027: { month: 10, day: 29 }, + 2028: { month: 10, day: 17 }, + 2029: { month: 11, day: 5 }, + 2030: { month: 10, day: 26 } + }, + + hanukkah: { + 2025: { month: 12, day: 14 }, + 2026: { month: 12, day: 4 }, + 2027: { month: 12, day: 24 }, + 2028: { month: 12, day: 12 }, + 2029: { month: 12, day: 2 }, + 2030: { month: 12, day: 22 } + } + }; +} + +/** @type { ( year: number ) => any } */ +function ComputeWesternEasterDate( year ) +{ + var a = year % 19; + var b = Math.floor( year / 100 ); + var c = year % 100; + var d = Math.floor( b / 4 ); + var e = b % 4; + var f = Math.floor( ( b + 8 ) / 25 ); + var g = Math.floor( ( b - f + 1 ) / 3 ); + var h = ( 19 * a + b - d - g + 15 ) % 30; + var i = Math.floor( c / 4 ); + var k = c % 4; + var l = ( 32 + 2 * e + 2 * i - h - k ) % 7; + var m = Math.floor( ( a + 11 * h + 22 * l ) / 451 ); + var month = Math.floor( ( h + l - 7 * m + 114 ) / 31 ); + var day = ( ( h + l - 7 * m + 114 ) % 31 ) + 1; + + return { + monthIndex: month - 1, + day: day + }; +} + +// ========================================================= +// UTILITIES +// ========================================================= + +/** @type { ( value: any ) => number } */ +function ToIntSafe( value ) +{ + var parsedValue = parseInt( value, 10 ); + return ( isNaN( parsedValue ) ? 0 : parsedValue ); +} + +/** @type { ( raw: any ) => any } */ +function SafeParseJson( raw ) +{ + try + { + return JSON.parse( String( raw || "" ) ); + } + catch( error ) + { + return null; + } +} + +/** @type { ( value: any ) => boolean } */ +function IsArray( value ) +{ + return Object.prototype.toString.call( value ) == "[object Array]"; +} + +/** @type { ( messageText: string ) => void } */ +function BroadcastMessage( messageText ) +{ + Console.Print( String( messageText || "" ) + "\n" ); +} \ No newline at end of file diff --git a/data/js/skill/mining.js b/data/js/skill/mining.js index 743de4ee0..57ecb77e7 100644 --- a/data/js/skill/mining.js +++ b/data/js/skill/mining.js @@ -620,6 +620,7 @@ function MakeOre( socket, mChar ) if( ValidateObject( oreItem )) { oreItem.name = oreName; + oreItem.sectionID = "ore"; if( oreData[6] != 0 ) { oreItem.AddScriptTrigger( oreData[6] ); // oreData[6] is scriptID diff --git a/docs/index.html b/docs/index.html index f0c898507..7186a7dce 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6881,6 +6881,454 @@

Characters

+
  • + Quest System +
    +
    + +

    + The Quest System adds structured, data-driven quests that can be offered by NPCs, auto-started at login, + or progressed through world actions such as kills, skill gains, entering regions, or item collection. +

    + +

    + Newer features include branching quest chains (conditional next steps), + QuestRootID-based NPC chains (NPCs do not need to be edited per step), + persistent world state for file-backed story flags and counters, + and Event Manager-powered Seasonal Quest Windows for holiday and festival quests that automatically start and end. +

    + +

    The system is modular and implemented through several scripts:

    + +
      +
    • js/server/quests/quest_core.js – Core engine (validation, start/complete, rewards, progress storage, world state support)
    • +
    • js/server/quests/quest_lists.js – Registry of all quest definitions
    • +
    • js/server/quests/quest_menu.js – Player quest log interface
    • +
    • js/server/quests/quest_conversation_gump.js – NPC dialog and accept/decline interface (supports branching preview and QuestRootID chain resolution)
    • +
    • js/server/quests/quest_item.js – Quest item tracking and marking
    • + +
    • js/server/quests/events/quest_event_ondeathblow.js – Kill progress tracking
    • +
    • js/server/quests/events/quest_event_onenterregion.js – Region progress tracking
    • +
    • js/server/quests/events/quest_event_onskillgain.js – Skill training quests
    • +
    • js/server/quests/events/quest_event_onquesttoggle.js – Accept / abandon tracking
    • + +
    • js/server/quests/quest_login.js – Optional login quest offer
    • + +
    • js/server/quests/seasonal/seasonal_event_quests.js – Seasonal quests using the Event Manager (holiday scheduling, pack support, questgiver spawns, custom event dates)
    • +
    + +

    Server Settings

    + +

    Enable quests in the server configuration:

    + +
    
    +UOX3/uox.ini
    +QuestsEnabled=1
    +
    + +

    Optional login quest:

    + +
    
    +js/server/global.js
    +const loginQuest = true;
    +
    + +

    Seasonal quest windows require the Event Manager system:

    + +
    
    +UOX3/uox.ini
    +EVENTMANAGERSYSTEM=1
    +
    + +

    Quick Setup

    + +
      +
    1. Enable quests – Set QuestsEnabled=1.
    2. +
    3. Place a quest NPC – Use an NPC from dfndata/npc/questgivers.dfn or your own DFN.
    4. +
    5. Assign a chain root – Tag the NPC with QuestRootID (preferred) or QuestID (legacy fallback).
    6. +
    7. Create quests – Add definitions in quest_lists.js.
    8. +
    9. Players can now accept quests.
    10. +
    + +

    Login Quest Offer

    + +

    If loginQuest is enabled, a starter quest can be offered when players log in.

    + + + + +
    +
      +
    • Set autoQuestID in quest_login.js.
    • +
    • Player settings track:
    • +
        +
      • SeenQuests
      • +
      • ShowLoginQuestOffers
      • +
      +
    • The system prevents repeat offers once accepted or declined.
    • +
    +
    + +

    Seasonal Quest Windows (Holiday Events)

    + +

    + Seasonal quests can be automatically enabled and disabled on a schedule using the Event Manager. + When a seasonal window starts, the system can spawn seasonal questgivers; when it ends, the NPCs are removed and the event is rescheduled for the next valid occurrence. +

    + +

    + The seasonal system supports a broader set of built-in holidays from multiple traditions, + and shard owners can choose which holiday packs they want enabled instead of loading every event at once. +

    + + + + +
    +
      +
    • Implemented in: js/server/quests/seasonal/seasonal_event_quests.js
    • +
    • Uses Event Manager script ID 5 to register ONCE events with duration, then re-register the next yearly occurrence when they end.
    • +
    • Built-in holidays are grouped into packs so shard owners can enable only the cultures or themes they want.
    • +
    • Admin can also add custom seasonal events with their own date, duration, NPC section, and spawn location.
    • +
    • Active seasonal questgiver spawns are persisted so script reloads can recover active seasonal windows.
    • +
    + +

    Built-in Holiday Packs

    +
      +
    • western
    • +
    • celtic_pagan
    • +
    • east_asian
    • +
    • south_asian
    • +
    • jewish
    • +
    • islamic
    • +
    • latin_american
    • +
    • irish
    • +
    • american
    • +
    • shard_fun
    • +
    • all
    • +
    + +

    Built-in Holiday Support

    +
      +
    • New Year’s Day
    • +
    • New Year’s Eve
    • +
    • Lunar New Year
    • +
    • Valentine’s Day
    • +
    • St. Patrick’s Day
    • +
    • Holi
    • +
    • Easter (computed automatically)
    • +
    • Ramadan
    • +
    • Eid al-Fitr
    • +
    • Vesak
    • +
    • Beltane / May Day
    • +
    • Dragon Boat Festival
    • +
    • Midsummer Festival
    • +
    • July 4th / Founders’ Day
    • +
    • Obon
    • +
    • Mid-Autumn Festival
    • +
    • Talk Like a Pirate Day
    • +
    • Harvest Festival
    • +
    • Halloween
    • +
    • Día de los Muertos / Day of the Dead
    • +
    • Bonfire Night
    • +
    • Diwali
    • +
    • Hanukkah
    • +
    • Winter Solstice
    • +
    • Christmas / Yuletide
    • +
    + +

    Scheduling Notes

    +
      +
    • Fixed-date holidays use month/day scheduling.
    • +
    • Easter is calculated automatically per year.
    • +
    • Lunar New Year, Holi, Ramadan, Eid al-Fitr, Vesak, Dragon Boat Festival, Mid-Autumn Festival, Diwali, and Hanukkah use built-in yearly lookup tables.
    • +
    • All seasonal event times are based on server-local time.
    • +
    + +

    Files Used

    +
      +
    • shared/seasonal_config.json – enabled holiday packs
    • +
    • shared/seasonal_custom_events.json – custom admin-added seasonal events
    • +
    • shared/seasonal_active.json – active seasonal state and spawned NPC serials
    • +
    + +

    GM Commands

    +
      +
    • [season list – list enabled built-in seasonal events plus custom seasonal events
    • +
    • [season catalog – list all available built-in holidays in the catalog
    • +
    • [season packs – list all holiday packs and whether they are enabled
    • +
    • [season active – list currently active seasonal windows
    • +
    • [season enablepack <packName> – enable a built-in holiday pack
    • +
    • [season disablepack <packName> – disable a built-in holiday pack
    • +
    • [season reload – re-register seasonal events from the current config
    • +
    • [season add <key> <month> <day> <hour> <minute> <durationHours> <npcSectionID> <x> <y> <z> <world> <instance>
    • +
    • [season remove <key> – removes a custom seasonal config entry
    • +
    + +

    Event Manager Notes

    +
      +
    • Changing enabled packs updates the seasonal config, but if events are already loaded you may still want to use [season reload or [em reset so Event Manager refreshes the schedule immediately.
    • +
    • If a custom event is already registered in Event Manager, removal can also be handled with [em remove.
    • +
    + +

    NPC Setup

    +
      +
    • Seasonal questgivers are normal quest NPCs that use the conversation script and a QuestRootID tag.
    • +
    • Recommended: put seasonal questgivers in a dedicated DFN, such as dfndata/npc/seasonal_questgivers.dfn.
    • +
    +
    + +

    Quest Parameters

    + + + + +
    + +
    +

    Core Details

    +
      +
    • title – Quest display name
    • +
    • description – Story text
    • +
    • uncomplete – NPC text while incomplete
    • +
    • complete – NPC text when finished
    • +
    • refuse – NPC text on decline
    • +
    • npcPhrases – Optional NPC chatter
    • +
    +
    + +
    +

    Quest Types

    +
      +
    • collect
    • +
    • timecollect
    • +
    • kill
    • +
    • timekills
    • +
    • multi
    • +
    • delivery
    • +
    • skillgain
    • +
    +
    + +
    +

    Flags & Control

    +
      +
    • oneTimeQuest – Quest can only be completed once
    • +
    • dailyQuest – Quest resets after timer
    • +
    • resetDailyTime – Hours before repeat
    • +
    • timeLimit – Optional quest timer
    • +
    • questTurnIn – NPC turn-in required
    • +
    • bankgold – Rewards go to bank instead of pack
    • +
    • requiresQuestID – Required prerequisite quest
    • +
    +
    + +
    +

    Objectives

    +
      +
    • targetItems – Items to collect
    • +
    • targetKills – Creatures to kill
    • +
    • targetRegion – Region requirement
    • +
    • targetSkill – Skill training quests
    • +
    • deliveryItem – Package item
    • +
    • targetDelivery – Delivery NPC
    • +
    +
    + +
    +

    Rewards

    +
      +
    • gold
    • +
    • items
    • +
    • fame
    • +
    • karma
    • +
    • skill rewards
    • +
    • skillpoints – pooled skill upgrades
    • +
    • virtue rewards
    • +
    +
    + +
    +

    Quest Chains

    +
      +
    • nextQuestID – Linear quest chain
    • +
    • nextQuest – Conditional branching quests
    • +
    +
    + +
    +

    Character Tags & World State

    +
      +
    • setTags – Set or remove character tags
    • +
    • setTagDeltas – Increment or decrement persistent tag values
    • +
    • deltaRules / deltaRulesDefault – Clamp rules for persistent tag deltas
    • +
    • setTempTags – Temporary character tags
    • +
    • setTempTagDeltas – Modify temporary tags
    • +
    • tempDeltaRules / tempDeltaRulesDefault – Clamp rules for temporary tag deltas
    • +
    • setWorldState – Set or remove persistent file-backed world-state values
    • +
    • worldStateDeltas – Increment or decrement world-state counters
    • +
    • worldStateDeltaRules / worldStateDeltaRulesDefault – Clamp rules for world-state deltas
    • +
    +
    + +
    + +

    Questgiver NPC Example

    + +
    
    +[mugg]
    +{
    +GET=basehuman
    +NAME=Mugg the Miner
    +TITLE=Quest Giver
    +SCRIPT=5802 // quest conversation
    +CUSTOMINTTAG=QuestRootID 1001
    +}
    +
    + +

    Seasonal Questgiver Example

    + +
    
    +[dl_halloween_questgiver]
    +{
    +GET=basehuman
    +NAME=Mora
    +TITLE=the Harvest Warden
    +SCRIPT=5802 // quest conversation
    +CUSTOMINTTAG=QuestRootID 9000
    +}
    +
    + +

    Quest Examples

    + + + + +
    + +
    
    +{
    +questID:1001,
    +title:"More Ore Please",
    +type:"collect",
    +
    +targetItems:[
    + { sectionID:"ironore", name:"Iron Ore", amount:20 }
    +],
    +
    +rewards:[
    + { type:"gold", amount:500 },
    + { type:"fame", amount:10 }
    +],
    +
    +oneTimeQuest:true
    +}
    +
    + +
    
    +{
    +questID:2000,
    +title:"Guard Recruitment",
    +type:"kill",
    +
    +targetKills:[
    + { npcID:"bandit", name:"Bandit", amount:5 }
    +],
    +
    +rewards:[
    + { type:"gold", amount:600 },
    + { type:"virtue", virtueIndex:6, amount:50 }
    +],
    +
    +setTagDeltas:{
    + "IronreachGuardStanding":5
    +},
    +
    +deltaRules:{
    + "IronreachGuardStanding":{ min:0, max:100 }
    +}
    +}
    +
    + +
    
    +{
    +questID:3000,
    +title:"Choose Your Path",
    +
    +nextQuest:[
    + { questID:3001, cond:{ tagMin:{ tag:"IronreachGuardStanding", value:10 } } },
    + { questID:3002, cond:{ always:true } }
    +]
    +}
    +
    + +
    
    +{
    +questID:4000,
    +title:"Aid the Orchard",
    +type:"collect",
    +
    +targetItems:[
    + { sectionID:"0x13e3", name:"Apple", amount:10 }
    +],
    +
    +rewards:[
    + { type:"gold", amount:500 }
    +],
    +
    +setWorldState:{
    + "dl_ws_orchard_helped":1
    +},
    +
    +worldStateDeltas:{
    + "dl_ws_ironreach_rep":5,
    + "dl_ws_compassion":10
    +},
    +
    +worldStateDeltaRulesDefault:{ min:0, max:1000 }
    +}
    +
    + +
    
    +{
    +questID:9000,
    +title:"Night of Lanterns",
    +type:"collect",
    +category:"Seasonal",
    +
    +targetItems:[
    + { sectionID:"0x0E2D", name:"Candle", amount:10 }
    +],
    +
    +rewards:[
    + { type:"gold", amount:750 },
    + { type:"item", sectionID:"dl_holiday_token", name:"Holiday Token", amount:5 }
    +],
    +
    +oneTimeQuest:true
    +}
    +
    + +
    + +

    Tips

    + +
      +
    • Prefer QuestRootID on NPCs so the same NPC can serve an entire chain without DFN edits per step.
    • +
    • Use oneTimeQuest for story content.
    • +
    • Use dailyQuest for repeatable quests.
    • +
    • Branching quests allow story decisions based on player reputation, completed quests, or world state.
    • +
    • Delivery quests automatically create a parcel item in the player's pack.
    • +
    • Use world state when you want persistent per-character story consequences without relying on normal player tags.
    • +
    • Seasonal quest windows pair well with Event Manager: spawn limited-time questgivers and reschedule yearly automatically.
    • +
    • Holiday packs let shard owners keep only the cultural or thematic holidays that fit their shard.
    • +
    • Custom seasonal dates are useful for shard anniversaries, lore celebrations, staff-run events, or local community festivals.
    • +
    + +
    +
    +
  • +
  • Race System
    diff --git a/source/CPacketReceive.cpp b/source/CPacketReceive.cpp index 1c8ba995e..d24bdbdb8 100644 --- a/source/CPacketReceive.cpp +++ b/source/CPacketReceive.cpp @@ -5412,6 +5412,44 @@ bool CPIPopupMenuSelect::Handle( void ) tSock->SysMessage( 393 ); // That is too far away } break; + case 0x010A: // Quest Toggle + if( mChar->GetCommandLevel() >= CL_ADMIN || cwmWorldState->creatures[targChar->GetId()].IsHuman()) // Only Humans + { + if( mChar->IsDead() ) + { + tSock->SysMessage( 392 ); // You are dead and cannot do that. + } + else if( !ObjInRange( mChar, targChar, DIST_NEARBY )) + { + tSock->SysMessage( 382 ); // You need to get closer. + } + else + { + CItem *iUsed = mChar->GetPackItem(); + bool scriptExecuted = false; + + for( auto scriptTrig : scriptTriggers ) + { + cScript *toExecute = JSMapping->GetScript( scriptTrig ); + if( toExecute != nullptr ) + { + // Check if onQuestToggle event is present in script + SI08 retVal = toExecute->onQuestToggle( mChar, iUsed ); + if( retVal == 1 ) + { + // Event exists, and returned 1 - proceed with other scripts/hard code + scriptExecuted = true; + } + else if( retVal == 0 ) + { + // Script returned 0 - don't continue, usage is handled in script! + return true; + } + } + } + } + } + break; default: Console.Print( oldstrutil::format( "Popup Menu Selection Called, Player: 0x%X Selection: 0x%X\n", tSock->GetDWord( 5 ), tSock->GetWord( 9 ))); break; diff --git a/source/CPacketSend.cpp b/source/CPacketSend.cpp index cd6f10567..d5febb4f5 100644 --- a/source/CPacketSend.cpp +++ b/source/CPacketSend.cpp @@ -9290,6 +9290,30 @@ void CPPopupMenu::CopyData( CBaseObject& toCopy, CSocket &tSock ) pStream.WriteShort( offset += 2, 0xFFFF ); // Hue of text } } + + // Toggle Quest Item On/Off + if( toCopyChar->GetQuestType() != QT_ESCORTQUEST && (( toCopy.GetSerial() == tSock.CurrcharObj()->GetSerial() + || toCopy.GetId() == 0x0123 || toCopy.GetId() == 0x0124 || toCopy.GetId() == 0x0317 ) && ValidateObject( toCopyChar->GetPackItem() ))) + { + if( numEntries > 0 ) + { + offset += 2; + } + + numEntries++; + pStream.WriteShort( offset, 0x010A ); // Unique ID + pStream.WriteShort( offset += 2, 6169 );// Quest Toggle + if( ObjInRange( mChar, &toCopy, 8 )) + { + pStream.WriteShort( offset += 2, 0x0020 ); // Flag, color enabled + pStream.WriteShort( offset += 2, 0x03E0 ); // Hue of text + } + else + { + pStream.WriteShort( offset += 2, 0x0021 ); // Flag, color enabled, entry disabled + pStream.WriteShort( offset += 2, 0xFFFF ); // Hue of text + } + } // Banker if( toCopyChar->IsNpc() && toCopyChar->GetNpcAiType() == AI_BANKER ) diff --git a/source/SEFunctions.cpp b/source/SEFunctions.cpp index e5174add8..eea536ff4 100644 --- a/source/SEFunctions.cpp +++ b/source/SEFunctions.cpp @@ -5750,6 +5750,9 @@ JSBool SE_GetServerSetting( JSContext *cx, uintN argc, jsval *vp ) case 389: // HOUSEDECAY JS_SET_RVAL( cx, vp, BOOLEAN_TO_JSVAL( cwmWorldState->ServerData()->HouseDecay() ) ); break; + case 395: // QUESTSENABLED + JS_SET_RVAL( cx, vp, BOOLEAN_TO_JSVAL( cwmWorldState->ServerData()->QuestsEnabled() ) ); + break; case 400: // SPEEDHACKDETECTTION JS_SET_RVAL( cx, vp, BOOLEAN_TO_JSVAL( cwmWorldState->ServerData()->SpeedHackDetection() )); break; diff --git a/source/cScript.cpp b/source/cScript.cpp index a2051b4bf..64420c872 100644 --- a/source/cScript.cpp +++ b/source/cScript.cpp @@ -1218,6 +1218,38 @@ std::string cScript::OnNameRequest( CBaseObject *myObj, CChar *nameRequester, UI } //o------------------------------------------------------------------------------------------------o +//| Function - cScript::onQuestToggle() +//o------------------------------------------------------------------------------------------------o +//| Purpose - Triggers for character with event attached and he click the contest +//| option toggle quest item. +//o------------------------------------------------------------------------------------------------o +SI08 cScript::onQuestToggle( CChar* toggler, CItem *iUsing ) +{ + const SI08 RV_NOFUNC = -1; + if( !ValidateObject( toggler) || !ValidateObject( iUsing )) + return RV_NOFUNC; + + if( !ExistAndVerify( seOnQuestToggle, "onQuestToggle" )) + return RV_NOFUNC; + + jsval rval, params[2]; + JSObject* togglerObj = JSEngine->AcquireObject( IUE_CHAR, toggler, runTime ); + JSObject *itemObj = JSEngine->AcquireObject( IUE_ITEM, iUsing, runTime ); + + params[0] = OBJECT_TO_JSVAL( togglerObj ); + params[1] = OBJECT_TO_JSVAL( itemObj ); + + JSBool retVal = InvokeEvent( "onQuestToggle", 2, params, &rval ); + if( retVal == JS_FALSE ) + { + SetEventExists( seOnQuestToggle, false ); + } + + return TryParseJSVal( rval ); +} + +//o------------------------------------------------------------------------------------------------o +//| Function - cScript::OnAttack() //| Function - cScript::OnAttack() //o------------------------------------------------------------------------------------------------o //| Purpose - Triggers for character with event attached when attacking someone diff --git a/source/cScript.h b/source/cScript.h index 22e11b0ca..c8ef864ad 100644 --- a/source/cScript.h +++ b/source/cScript.h @@ -27,6 +27,7 @@ enum ScriptEvent seOnStat, seOnTooltip, seOnNameRequest, + seOnQuestToggle, seOnAttack, seOnDefense, seOnSkillGain, // ** @@ -232,8 +233,9 @@ class cScript std::string OnProfileRequest( CSocket *mSock, CChar *profileOwner ); std::string OnTooltip( CBaseObject *myObj, CSocket *pSocket ); std::string OnNameRequest( CBaseObject *myObj, CChar *nameRequester, UI08 requestSource ); - bool OnAttack( CChar *attacker, CChar *defender, bool hitStatus, SI08 hitLoc, UI16 damageDealt ); - bool OnDefense( CChar *attacker, CChar *defender, bool hitStatus, SI08 hitLoc, UI16 damageReceived ); + SI08 onQuestToggle( CChar *player, CItem *iUsing ); + bool OnAttack( CChar *attacker, CChar *defender, bool hitStatus, SI08 hitLoc, UI16 damageDealt ); + bool OnDefense( CChar *attacker, CChar *defender, bool hitStatus, SI08 hitLoc, UI16 damageReceived ); SI08 OnSkillGain( CChar *player, SI08 skill, UI32 skillAmtGained ); SI08 OnSkillLoss( CChar *player, SI08 skill, UI32 skillAmtLost ); bool OnSkillChange( CChar *player, SI08 skill, SI32 skillAmtChanged ); diff --git a/source/cServerData.cpp b/source/cServerData.cpp index 3889bd36b..b4709aff3 100644 --- a/source/cServerData.cpp +++ b/source/cServerData.cpp @@ -410,6 +410,7 @@ const std::map CServerData::uox3IniCaseValue {"DECAYSTAGEHIHRS"s, 387}, {"DECAYSTAGEDANGERHRS"s, 388}, {"HOUSEDECAY"s, 389}, + {"QUESTSENABLED"s, 395}, {"SPEEDHACKDETECTION", 400}, {"SPEEDHACKMAXDEBT", 401}, {"SPEEDHACKMAXDEBTAVG", 402}, @@ -538,6 +539,7 @@ constexpr auto BIT_HOUSEITEMSDELETEONDECAY = UI32( 112 ); constexpr auto BIT_HOUSEGRANDFATHERED = UI32( 113 ); constexpr auto BIT_SPEEDHACKDETECTION = UI32( 119 ); constexpr auto BIT_EVENTMANAGERSYSTEM = UI32( 120 ); +constexpr auto BIT_QUESTSENABLED = UI32( 121 ); // New uox3.ini format lookup @@ -684,6 +686,7 @@ auto CServerData::ResetDefaults() -> void InternalAccountStatus( true ); YoungPlayerSystem( true ); KarmaLocking( true ); + QuestsEnabled( true ); CombatMaxRange( 10 ); CombatMaxSpellRange( 10 ); CombatMaxNpcAggroRange( 10 ); @@ -2408,6 +2411,20 @@ auto CServerData::KarmaLocking( bool newVal ) -> void boolVals.set( BIT_KARMALOCKING, newVal ); } +//o------------------------------------------------------------------------------------------------o +//| Function - CServerData::QuestsEnabled() +//o------------------------------------------------------------------------------------------------o +//| Purpose - Gets/Sets whether the QuestsEnabled system is enabled +//o------------------------------------------------------------------------------------------------o +auto CServerData::QuestsEnabled() const -> bool +{ + return boolVals.test( BIT_QUESTSENABLED ); +} +auto CServerData::QuestsEnabled( bool newVal ) -> void +{ + boolVals.set( BIT_QUESTSENABLED, newVal ); +} + //o------------------------------------------------------------------------------------------------o //| Function - CServerData::ShowOfflinePCs() //o------------------------------------------------------------------------------------------------o @@ -5790,6 +5807,7 @@ auto CServerData::SaveIni( const std::string &filename ) -> bool ofsOutput << "ENABLENPCGUILDPREMIUMS=" << ( EnableNPCGuildPremiums() ? 1 : 0 ) << '\n'; ofsOutput << "YOUNGPLAYERSYSTEM=" << ( YoungPlayerSystem() ? 1 : 0 ) << '\n'; ofsOutput << "KARMALOCKING=" << ( KarmaLocking() ? 1 : 0 ) << '\n'; + ofsOutput << "QUESTSENABLED=" << ( QuestsEnabled() ? 1 : 0 ) << '\n'; ofsOutput << "}" << '\n'; ofsOutput << '\n' << "[pets and followers]" << '\n' << "{" << '\n'; @@ -7522,6 +7540,9 @@ auto CServerData::HandleLine( const std::string& tag, const std::string& value ) case 389: // HOUSEDECAY HouseDecay( ( static_cast( std::stoul( value, nullptr, 0 ) ) >= 1 ? true : false ) ); break; + case 395: // QUESTSENABLED + QuestsEnabled( static_cast( std::stoul( value, nullptr, 0 )) != 0 ); + break; case 400: // SPEEDHACKDETECTION SpeedHackDetection(( static_cast( std::stoul( value, nullptr, 0 )) >= 1 ? true : false )); break; diff --git a/source/cServerData.h b/source/cServerData.h index d14a88ac5..d0650cc12 100644 --- a/source/cServerData.h +++ b/source/cServerData.h @@ -212,7 +212,7 @@ class CServerData // Once over 62, bitsets are costly. std::vector has a special exception in the c++ specificaiton, to minimize wasted space for bools // These should be updated - std::bitset<121> boolVals; // Many values stored this way, rather than using bools. + std::bitset<122> boolVals; // Many values stored this way, rather than using bools. std::bitset<64> spawnRegionsFacets; // Used to determine which facets to enable spawn regions for, set in UOX>INI std::bitset<64> moongateFacets; // Used to determine which facets to enable moongates for, set in UOX>INI @@ -676,6 +676,9 @@ class CServerData auto KarmaLocking( bool value ) -> void; auto KarmaLocking() const -> bool; + auto QuestsEnabled( bool value ) -> void; + auto QuestsEnabled() const -> bool; + auto ShowOfflinePCs( bool value ) -> void; auto ShowOfflinePCs() const -> bool;