diff --git a/data/events/events.xml b/data/events/events.xml
index 8d28bd0312..088de6ba95 100644
--- a/data/events/events.xml
+++ b/data/events/events.xml
@@ -46,5 +46,5 @@
-
+
diff --git a/data/lib/core/core.lua b/data/lib/core/core.lua
index e3c3992702..612f228fed 100644
--- a/data/lib/core/core.lua
+++ b/data/lib/core/core.lua
@@ -22,3 +22,4 @@ dofile('data/lib/core/teleport.lua')
dofile('data/lib/core/tile.lua')
dofile('data/lib/core/town.lua')
dofile('data/lib/core/vocation.lua')
+dofile('data/lib/core/feature12plus/feature12plus.lua')
diff --git a/data/lib/core/feature12plus/bosstiary.lua b/data/lib/core/feature12plus/bosstiary.lua
new file mode 100644
index 0000000000..3b9c5d125c
--- /dev/null
+++ b/data/lib/core/feature12plus/bosstiary.lua
@@ -0,0 +1,841 @@
+local CONST = {
+ SLOT = {
+ FIRST = 1,
+ SECOND = 2,
+ THIRD = 3,
+
+ FIRST_STORAGE = PlayerStorageKeys.bosstiarySlot1,
+ SECOND_STORAGE = PlayerStorageKeys.bosstiarySlot2
+ },
+ BOSS_TYPE = {
+ BANE = 0,
+ ARCHFOE = 1,
+ NEMESIS = 2
+ },
+ KILLS = {
+ BANE = {
+ PROWESS = 25,
+ EXPERTISE = 100,
+ MASTERY = 300
+ },
+ ARCHFOE = {
+ PROWESS = 5,
+ EXPERTISE = 20,
+ MASTERY = 60
+ },
+ NEMESIS = {
+ PROWESS = 1,
+ EXPERTISE = 3,
+ MASTERY = 5
+ }
+ },
+ POINTS = {
+ BANE = {
+ PROWESS = 5,
+ EXPERTISE = 15,
+ MASTERY = 30
+ },
+ ARCHFOE = {
+ PROWESS = 10,
+ EXPERTISE = 30,
+ MASTERY = 60
+ },
+ NEMESIS = {
+ PROWESS = 10,
+ EXPERTISE = 30,
+ MASTERY = 60
+ }
+ },
+ SECOND_SLOOT_POINTS = 1500,
+ TODAY_BOOSTED_BOSS_KILL_COUNT = 3,
+ BOOSTED_BOSS_KILL_BONUS = 25,
+ TODAY_BOOSTED_BOSS_KILL_BONUS = 250,
+ REMOVE_BOSS_COST_BASE = 100000,
+ REMOVE_BOSS_COST_STEP = 300000,
+}
+
+-- For optimization to avoid iteration over all boss records
+-- Array holds information if players reached first milestone on any boss record
+local firstMilestoneUnlocked = {}
+
+local bossCount = 0
+
+local bossesByName = {
+ ["Abyssador"] = { id = 887, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Alptramun"] = { id = 1698, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Ancient Spawn of Morgathla"] = { id = 1551, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Annihilon"] = { id = 418, bossType = CONST.BOSS_TYPE.BANE },
+ ["Anomaly"] = { id = 1219, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Arachir The Ancient One"] = { id = 478, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Ashmunrah"] = { id = 91, bossType = CONST.BOSS_TYPE.BANE },
+ ["Barbaria"] = { id = 440, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Battlemaster Zunzu"] = { id = 635, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Bibby Bloodbath"] = { id = 900, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Big Boss Trolliver"] = { id = 432, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Black Knight"] = { id = 46, bossType = CONST.BOSS_TYPE.BANE },
+ ["Black Vixen"] = { id = 1559, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Bloodback"] = { id = 1560, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Boogey"] = { id = 567, bossType = CONST.BOSS_TYPE.BANE },
+ ["Bragrumol"] = { id = 1828, bossType = CONST.BOSS_TYPE.BANE },
+ ["Brokul"] = { id = 1645, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Bullwark"] = { id = 1060, bossType = CONST.BOSS_TYPE.BANE },
+ ["Captain Jones"] = { id = 430, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Chikhaton"] = { id = 647, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Chizzoron The Distorter"] = { id = 629, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Count Vlarkorth"] = { id = 1753, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Countess Sorrow"] = { id = 306, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Custodian"] = { id = 1770, bossType = CONST.BOSS_TYPE.BANE },
+ ["Darkfang"] = { id = 1558, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Dazed Leaf Golem"] = { id = 1578, bossType = CONST.BOSS_TYPE.BANE },
+ ["Death Priest Shargon"] = { id = 1047, bossType = CONST.BOSS_TYPE.BANE },
+ ["Deathstrike"] = { id = 892, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Deep Terror"] = { id = 1087, bossType = CONST.BOSS_TYPE.BANE },
+ ["Dharalion"] = { id = 203, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Diblis The Fair"] = { id = 477, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Dipthrah"] = { id = 87, bossType = CONST.BOSS_TYPE.BANE },
+ ["Dirtbeard"] = { id = 565, bossType = CONST.BOSS_TYPE.BANE },
+ ["Diseased Bill"] = { id = 485, bossType = CONST.BOSS_TYPE.BANE },
+ ["Diseased Dan"] = { id = 486, bossType = CONST.BOSS_TYPE.BANE },
+ ["Diseased Fred"] = { id = 484, bossType = CONST.BOSS_TYPE.BANE },
+ ["Doctor Perhaps"] = { id = 564, bossType = CONST.BOSS_TYPE.BANE },
+ ["Dracola"] = { id = 302, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Dreadmaw"] = { id = 639, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Drume"] = { id = 1957, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Duke Krule"] = { id = 1758, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Earl Osam"] = { id = 1757, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Ekatrix"] = { id = 1140, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Eradicator"] = { id = 1225, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Essence Of Malice"] = { id = 1487, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Evil Mastermind"] = { id = 569, bossType = CONST.BOSS_TYPE.BANE },
+ ["Faceless Bane"] = { id = 1727, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Feroxa"] = { id = 1149, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Ferumbras"] = { id = 231, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Ferumbras Mortal Shell"] = { id = 1206, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Fleabringer"] = { id = 640, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Fleshslicer"] = { id = 858, bossType = CONST.BOSS_TYPE.BANE },
+ ["Foreman Kneebiter"] = { id = 424, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Furyosa"] = { id = 987, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Gaffir"] = { id = 1778, bossType = CONST.BOSS_TYPE.BANE },
+ ["Gaz'Haragoth"] = { id = 1003, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Gelidrazah the Frozen"] = { id = 1379, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["General Murius"] = { id = 207, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Ghazbaran"] = { id = 312, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Ghulosh"] = { id = 1608, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Glitterscale"] = { id = 670, bossType = CONST.BOSS_TYPE.BANE },
+ ["Glooth Fairy"] = { id = 1058, bossType = CONST.BOSS_TYPE.BANE },
+ ["Gnomevil"] = { id = 893, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Golgordan"] = { id = 416, bossType = CONST.BOSS_TYPE.BANE },
+ ["Gorzindel"] = { id = 1591, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Grand Canon Dominus"] = { id = 1584, bossType = CONST.BOSS_TYPE.BANE },
+ ["Grand Chaplain Gaunder"] = { id = 1579, bossType = CONST.BOSS_TYPE.BANE },
+ ["Grand Commander Soeren"] = { id = 1582, bossType = CONST.BOSS_TYPE.BANE },
+ ["Grand Master Oberon"] = { id = 1576, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Grand Mother Foulscale"] = { id = 642, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Grandfather Tridian"] = { id = 431, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Gravelord Oshuran"] = { id = 426, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Groam"] = { id = 736, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Grorlam"] = { id = 205, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Guard Captain Quaid"] = { id = 1791, bossType = CONST.BOSS_TYPE.BANE },
+ ["Hairman The Huge"] = { id = 425, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Hellgorak"] = { id = 403, bossType = CONST.BOSS_TYPE.BANE },
+ ["Heoni"] = { id = 671, bossType = CONST.BOSS_TYPE.BANE },
+ ["Hirintror"] = { id = 967, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Horestis"] = { id = 713, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Irgix The Flimsy"] = { id = 1890, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Izcandar the Banished"] = { id = 1699, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Jailer"] = { id = 1577, bossType = CONST.BOSS_TYPE.BANE },
+ ["Jaul"] = { id = 773, bossType = CONST.BOSS_TYPE.BANE },
+ ["Jesse The Wicked"] = { id = 763, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Kalyassa"] = { id = 1389, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["King Zelos"] = { id = 1784, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Kroazur"] = { id = 1515, bossType = CONST.BOSS_TYPE.BANE },
+ ["Lady Tenebris"] = { id = 1315, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Latrivan"] = { id = 417, bossType = CONST.BOSS_TYPE.BANE },
+ ["Lisa"] = { id = 1059, bossType = CONST.BOSS_TYPE.BANE },
+ ["Lloyd"] = { id = 1329, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Lokathmor"] = { id = 1574, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Lord Azaram"] = { id = 1756, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Lord of the Elements"] = { id = 454, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Mad Mage"] = { id = 703, bossType = CONST.BOSS_TYPE.BANE },
+ ["Madareth"] = { id = 414, bossType = CONST.BOSS_TYPE.BANE },
+ ["Mahrdis"] = { id = 86, bossType = CONST.BOSS_TYPE.BANE },
+ ["Malofur Mangrinder"] = { id = 1696, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Man In The Cave"] = { id = 338, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Massacre"] = { id = 305, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Mawhawk"] = { id = 1028, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Maxxenius"] = { id = 1697, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Mazoran"] = { id = 1186, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Mazzinor"] = { id = 1605, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Melting Frozen Horror"] = { id = 1336, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Mephiles"] = { id = 566, bossType = CONST.BOSS_TYPE.BANE },
+ ["Monstor"] = { id = 568, bossType = CONST.BOSS_TYPE.BANE },
+ ["Morgaroth"] = { id = 229, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Morguthis"] = { id = 84, bossType = CONST.BOSS_TYPE.BANE },
+ ["Mozradek"] = { id = 1829, bossType = CONST.BOSS_TYPE.BANE },
+ ["Mr. Punish"] = { id = 303, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Obujos"] = { id = 774, bossType = CONST.BOSS_TYPE.BANE },
+ ["Ocyakao"] = { id = 970, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Omrafir"] = { id = 1011, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Omruc"] = { id = 90, bossType = CONST.BOSS_TYPE.BANE },
+ ["Orshabaal"] = { id = 201, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Outburst"] = { id = 1227, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Plagirath"] = { id = 1199, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Plagueroot"] = { id = 1695, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Preceptor Lazare"] = { id = 1583, bossType = CONST.BOSS_TYPE.BANE },
+ ["Professor Maxxen"] = { id = 1093, bossType = CONST.BOSS_TYPE.BANE },
+ ["Ragiaz"] = { id = 1180, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Raging mage"] = { id = 718, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Rahemos"] = { id = 88, bossType = CONST.BOSS_TYPE.BANE },
+ ["Ravenous Hunger"] = { id = 1427, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Raxias"] = { id = 1624, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Razzagorn"] = { id = 1177, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Realityquake"] = { id = 1218, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Robby The Reckless"] = { id = 764, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Rotworm Queen"] = { id = 459, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Rukor Zad"] = { id = 435, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Rupture"] = { id = 1225, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Scarlett Etzel"] = { id = 1801, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Shadowpelt"] = { id = 1561, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Sharpclaw"] = { id = 1562, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Shulgrax"] = { id = 1191, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Sir Baeloc"] = { id = 1755, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Sir Nictros"] = { id = 1754, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Sir Valorcrest"] = { id = 476, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Smuggler Baron Silvertoe"] = { id = 436, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Soul of Dragonking Zyrtarch"] = { id = 1289, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Tanjis"] = { id = 775, bossType = CONST.BOSS_TYPE.BANE },
+ ["Tarbaz"] = { id = 1188, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Tazhadur"] = { id = 1390, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Thalas"] = { id = 89, bossType = CONST.BOSS_TYPE.BANE },
+ ["Thawing Dragon Lord"] = { id = 1585, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Abomination"] = { id = 373, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Armored Voidborn"] = { id = 1406, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Baron From Below"] = { id = 1518, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Blazing Rose"] = { id = 1600, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Blightfather"] = { id = 638, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Count Of The Core"] = { id = 1519, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Diamond Blossom"] = { id = 1598, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Duke Of The Depths"] = { id = 1520, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Enraged Thorn Knight"] = { id = 1297, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Evil Eye"] = { id = 210, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The False God"] = { id = 1409, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Fear Feaster"] = { id = 1873, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Frog Prince"] = { id = 420, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Handmaiden"] = { id = 301, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Imperor"] = { id = 304, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Last Lore Keeper"] = { id = 1304, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Lily of Night"] = { id = 1602, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Lord of the Lice"] = { id = 1179, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Mutated Pumpkin"] = { id = 466, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Nightmare Beast"] = { id = 1718, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Pale Count"] = { id = 972, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Percht Queen"] = { id = 1744, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Plasmother"] = { id = 300, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Ravager"] = { id = 1035, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Sandking"] = { id = 1444, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Scourge Of Oblivion"] = { id = 1642, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Shatterer"] = { id = 1165, bossType = CONST.BOSS_TYPE.BANE },
+ ["The Souldespoiler"] = { id = 1422, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Source Of Corruption"] = { id = 1500, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Time Guardian"] = { id = 1290, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Unarmored Voidborn"] = { id = 1406, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["The Voice of Ruin"] = { id = 636, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["The Welter"] = { id = 964, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Tyrn"] = { id = 966, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Unaz the Mean"] = { id = 1891, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Urmahlullu the Weakened"] = { id = 1811, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Ushuriel"] = { id = 415, bossType = CONST.BOSS_TYPE.BANE },
+ ["Vashresamun"] = { id = 85, bossType = CONST.BOSS_TYPE.BANE },
+ ["Vok The Freakish"] = { id = 1892, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Warlord Ruzad"] = { id = 419, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["White Pale"] = { id = 965, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Willi Wasp"] = { id = 955, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["World Devourer"] = { id = 1228, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Xenia"] = { id = 428, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Xogixath"] = { id = 1827, bossType = CONST.BOSS_TYPE.BANE },
+ ["Yaga The Crone"] = { id = 427, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Yakchal"] = { id = 336, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Zamulosh"] = { id = 1181, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Zarabustor"] = { id = 421, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Zevelon Duskbringer"] = { id = 475, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Zomba"] = { id = 956, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Zorvorax"] = { id = 1375, bossType = CONST.BOSS_TYPE.ARCHFOE },
+ ["Zugurosh"] = { id = 434, bossType = CONST.BOSS_TYPE.BANE },
+ ["Zulazza The Corruptor"] = { id = 628, bossType = CONST.BOSS_TYPE.NEMESIS },
+ ["Zushuka"] = { id = 969, bossType = CONST.BOSS_TYPE.NEMESIS },
+}
+
+local bossesById = {}
+do
+ for _, bossData in pairs(bossesByName) do
+ bossesById[bossData.id] = {
+ name = _,
+ bossType = bossData.bossType
+ }
+ bossCount = bossCount + 1
+ end
+end
+
+local todayResetCostTime
+do
+ local now = os.time()
+ local date = os.date("*t", now)
+ date.hour = 0
+ date.min = 0
+ date.sec = 0
+
+ local start_of_day = os.time(date)
+
+ -- add 10h according to Shutdown event -> globalevent name="Server Save" time="09:55:00" script="server_save.lua"
+ todayResetCostTime = start_of_day + (10 * 3600)
+end
+
+-- PRIVATE API --
+
+local function rollBoostedBoss()
+ local keys = {}
+ for key in pairs(bossesByName) do
+ table.insert(keys, key)
+ end
+
+ local randomKey
+ repeat
+ randomKey = keys[math.random(#keys)]
+ until randomKey.id ~= 0 -- 0 is for remove slot, is not valid boss
+ return bossesByName[randomKey]
+end
+
+local function isBossUnlocked(bossId, kills)
+ local type = bossesById[bossId].bossType
+
+ if type == CONST.BOSS_TYPE.BANE then
+ if kills >= CONST.KILLS.BANE.PROWESS then
+ return true
+ end
+ elseif type == CONST.BOSS_TYPE.ARCHFOE then
+ if kills >= CONST.KILLS.ARCHFOE.PROWESS then
+ return true
+ end
+ elseif type == CONST.BOSS_TYPE.NEMESIS then
+ if kills >= CONST.KILLS.NEMESIS.PROWESS then
+ return true
+ end
+ end
+ return false
+end
+
+local todayBoostedBoss = rollBoostedBoss()
+
+local function isValidSlot(slot)
+ if slot ~= CONST.SLOT.FIRST and slot ~= CONST.SLOT.SECOND then
+ return false
+ end
+ return true
+end
+
+local function isValidBoss(boss)
+ if boss == 0 then
+ return true
+ end
+
+ if bossesByName[boss] ~= nil then
+ return true
+ end
+
+ if bossesById[boss] ~= nil then
+ return true
+ end
+
+ return false
+end
+
+
+local function getPointsForNextLevel(level)
+ if level <= 25 then
+ return 10 * level
+ elseif level <= 75 then
+ return 250 + (level - 25) * 20
+ else
+ local total_points = 1250
+ local increment = 25
+ for lvl = 76, level do
+ total_points = total_points + increment
+ increment = increment + 5
+ end
+ return total_points
+ end
+end
+
+ local function getCurrentLevelFromPoints(points)
+ if points < 250 then
+ return math.floor(points / 10)
+ elseif points < 1250 then
+ return 25 + math.floor((points - 250) / 20)
+ else
+ local level = 75
+ local remaining_points = points - 1250
+ local increment = 25
+ while remaining_points > 0 do
+ remaining_points = remaining_points - increment
+ if remaining_points >= 0 then
+ level = level + 1
+ increment = increment + 5
+ end
+ end
+ return level
+ end
+end
+
+local function isFirstSlotUnlocked(player, bossId, bossType)
+ local countToUnlock = 0
+ if bossType == CONST.BOSS_TYPE.BANE then
+ countToUnlock = CONST.KILLS.BANE.PROWESS
+ elseif bossType == CONST.BOSS_TYPE.ARCHFOE then
+ countToUnlock = CONST.KILLS.ARCHFOE.PROWESS
+ elseif bossType == CONST.BOSS_TYPE.NEMESIS then
+ countToUnlock = CONST.KILLS.NEMESIS.PROWESS
+ end
+
+ local kills = player:getBosstiaryKills(bossId)
+ if kills >= countToUnlock then
+ return true
+ end
+
+ return false
+end
+
+local function addBosstiaryPointsIfNeeded(player, boss, step, oldCount, newCount, points)
+ if oldCount < step.PROWESS and newCount >= step.PROWESS then
+ player:addBosstiaryPoints(points.PROWESS)
+ end
+
+ if oldCount < step.EXPERTISE and newCount >= step.EXPERTISE then
+ player:addBosstiaryPoints(points.EXPERTISE)
+ end
+
+ if oldCount < step.MASTERY and newCount >= step.MASTERY then
+ player:addBosstiaryPoints(points.MASTERY)
+ end
+end
+
+-- PUBLIC API --
+
+Bosstiary = {}
+
+Bosstiary.getConst = function()
+ return CONST
+end
+
+Bosstiary.getBossesByName = function()
+ return bossesByName
+end
+
+Bosstiary.getBossesCount = function()
+ return bossCount
+end
+
+Bosstiary.getBossesById = function()
+ return bossesById
+end
+
+Bosstiary.getTodayBoostedBoss = function()
+ return todayBoostedBoss
+end
+
+Bosstiary.getBosstiaryRemoveCostResetTime = function()
+ return todayResetCostTime
+end
+
+function Bosstiary.getBossById(bossId)
+ if bossesById[bossId] ~= nil then
+ return bossesById[bossId]
+ end
+
+ return 0
+end
+
+function Bosstiary.getBossByName(bossName)
+ if bossesByName[bossName] ~= nil then
+ return bossesByName[bossName]
+ end
+
+ return ""
+end
+
+-- POINTS
+function Player.getBosstiaryPoints(self)
+ return math.max(0, self:getStorageValue(PlayerStorageKeys.bosstiaryPoints) or 0)
+end
+
+function Player.getBosstiaryNextLevelPoints(self)
+ return getPointsForNextLevel(getCurrentLevelFromPoints(math.max(0, self:getStorageValue(PlayerStorageKeys.bosstiaryPoints) or 0)) + 1)
+end
+
+function Player.addBosstiaryPoints(self, points)
+ return self:setStorageValue(PlayerStorageKeys.bosstiaryPoints,
+ self:getBosstiaryPoints() + math.max(0, points))
+end
+
+function Player.setBosstiaryPoints(self, points)
+ return self:setStorageValue(PlayerStorageKeys.bosstiaryPoints, math.max(0, points))
+end
+
+function Player.addBosstiaryPointsIfNeeded(self, boss, oldCount, newCount)
+ local bossType = Bosstiary.getBossById(boss).bossType
+ local steps = CONST.KILLS.BANE
+ local points = CONST.POINTS.BANE
+ if bossType == CONST.BOSS_TYPE.BANE then
+ steps = CONST.KILLS.BANE
+ points = CONST.POINTS.BANE
+ elseif bossType == CONST.BOSS_TYPE.ARCHFOE then
+ steps = CONST.KILLS.ARCHFOE
+ points = CONST.POINTS.ARCHFOE
+ else
+ steps = CONST.KILLS.NEMESIS
+ points = CONST.POINTS.NEMESIS
+ end
+ addBosstiaryPointsIfNeeded(self, boss, steps, oldCount, newCount, points)
+end
+
+-- SLOTS
+-- "boss" can be name or id
+function Player.setBosstiarySlotBoss(self, slot, boss)
+ if not isValidSlot(slot) then
+ return false
+ end
+
+ if not isValidBoss(boss) then
+ return false
+ end
+
+ if not isNumber(boss) then
+ boss = Bosstiary.getBossByName(boss).id
+ end
+
+ if slot == CONST.SLOT.FIRST then
+ self:setStorageValue(CONST.SLOT.FIRST_STORAGE, boss)
+ else
+ self:setStorageValue(CONST.SLOT.SECOND_STORAGE, boss)
+ end
+
+ return true
+end
+
+function Player.getBosstiarySlotBoss(self, slot)
+ if not isValidSlot(slot) then
+ return 0
+ end
+
+ if slot == CONST.SLOT.FIRST then
+ return math.max(0, self:getStorageValue(CONST.SLOT.FIRST_STORAGE) or 0)
+ else
+ return math.max(0, self:getStorageValue(CONST.SLOT.SECOND_STORAGE) or 0)
+ end
+end
+
+function Player.isValidBoostiaryBoss(self, boss)
+ if boss == 0 then
+ return true
+ end
+
+ if todayBoostedBoss then
+ if todayBoostedBoss.id == boss then
+ return false
+ end
+ end
+
+ if self:getBosstiarySlotBoss(CONST.SLOT.FIRST) == boss then
+ return false
+ end
+
+ if self:getBosstiarySlotBoss(CONST.SLOT.SECOND) == boss then
+ return false
+ end
+
+ return true
+end
+
+-- KILLS
+-- "boss" can be name or id
+function Player.getBosstiaryKills(self, boss)
+ if not isValidBoss(boss) then
+ return 0
+ end
+
+ if not isNumber(boss) then
+ boss = Bosstiary.getBossByName(boss).id
+ end
+
+ if self:getGroup():getAccess() then
+ return 1000
+ end
+ return math.max(0, self:getStorageValue(PlayerStorageKeys.bosstiaryKillsBase + boss) or 0)
+end
+
+-- "boss" can be name or id
+function Player.setBosstiaryKills(self, boss, points)
+ if not isValidBoss(boss) then
+ return false
+ end
+
+ if not isNumber(boss) then
+ boss = Bosstiary.getBossByName(boss).id
+ end
+
+ return self:setStorageValue(PlayerStorageKeys.bosstiaryKillsBase + boss, math.max(0, points))
+end
+
+function Player.addBosstiaryKills(self, boss, points)
+ if not isValidBoss(boss) then
+ return false
+ end
+
+ if not isNumber(boss) then
+ boss = Bosstiary.getBossByName(boss).id
+ end
+
+ local oldCount = self:getBosstiaryKills(boss)
+ local newCount = oldCount + math.max(0, points)
+
+ self:addBosstiaryPointsIfNeeded(boss, oldCount, newCount)
+
+ return self:setStorageValue(PlayerStorageKeys.bosstiaryKillsBase + boss, newCount)
+end
+
+-- BONUS
+function Player.getBosstiaryBonusValue(self, slot)
+ if slot == CONST.SLOT.FIRST or slot == CONST.SLOT.SECOND then
+ return CONST.BOOSTED_BOSS_KILL_BONUS + getCurrentLevelFromPoints(self:getBosstiaryPoints())
+ elseif slot == CONST.SLOT.THIRD then
+ return CONST.TODAY_BOOSTED_BOSS_KILL_BONUS
+ end
+
+ return getCurrentLevelFromPoints(self:getBosstiaryPoints())
+end
+
+function Player.getBosstiaryNextLevelBonusValue(self)
+ return getCurrentLevelFromPoints(self:getBosstiaryPoints()) + 1
+end
+
+-- REMOVE COST
+function Player.getBosstiaryRemoveCost(self)
+ local currentCount = self:getBosstiaryTodayRemoveCount()
+ if currentCount <= 1 then
+ return 0
+ end
+
+ return (math.max(0, self:getBosstiaryTodayRemoveCount() - 2) * CONST.REMOVE_BOSS_COST_STEP) + CONST.REMOVE_BOSS_COST_BASE
+end
+
+-- REMOVE COUNT
+function Player.getBosstiaryTodayRemoveCount(self)
+ return math.max(1, self:getStorageValue(PlayerStorageKeys.bosstiaryTodayRemoveCount) or 1)
+end
+
+function Player.setBosstiaryTodayRemoveCount(self, value)
+ return self:setStorageValue(PlayerStorageKeys.bosstiaryTodayRemoveCount, value)
+end
+
+function Player.incrementBosstiaryRemoveCounter(self)
+ return self:setBosstiaryTodayRemoveCount(self:getBosstiaryTodayRemoveCount() + 1)
+end
+
+function Player.resetBestiaryRemoveCountIfNeeded(self)
+ if self:getBosstiaryLastRemoveDate() ~= Bosstiary.getBosstiaryRemoveCostResetTime() then
+ return self:setBosstiaryTodayRemoveCount(0)
+ end
+end
+
+-- REMOVE TIME
+function Player.setBosstiaryLastRemoveDate(self)
+ return self:setStorageValue(PlayerStorageKeys.bosstiaryTodayRemoveDate, Bosstiary.getBosstiaryRemoveCostResetTime())
+end
+
+function Player.getBosstiaryLastRemoveDate(self)
+ return self:getStorageValue(PlayerStorageKeys.bosstiaryTodayRemoveDate)
+end
+
+-- SLOT UNLOCKED
+function Player.isBosstiarySlotUnlocked(self, slot)
+ if not isValidSlot(slot) then
+ return false
+ end
+
+ if slot == CONST.SLOT.FIRST then
+ if firstMilestoneUnlocked[self:getGuid()] == true then
+ return true
+ else
+ for _, bossData in pairs(Bosstiary.getBossesByName()) do
+ if isFirstSlotUnlocked(self, bossData.id, bossData.bossType) then
+ firstMilestoneUnlocked[self:getGuid()] = true
+ return true
+ end
+ end
+ end
+ else
+ if self:getBosstiaryPoints() >= CONST.SECOND_SLOOT_POINTS then
+ return true
+ end
+ end
+ return false
+end
+
+-- NETWORK MESSAGE
+function Player.sendBosstiaryList(self)
+ local msg = NetworkMessage()
+ msg:addByte(0x73)
+ msg:addU16(Bosstiary.getBossesCount())
+ for _, boss in pairs(bossesByName) do
+ msg:addU32(boss.id)
+ msg:addByte(boss.bossType)
+ msg:addU32(self:getBosstiaryKills(boss.id))
+ msg:addByte(0) -- unknown
+ end
+
+ msg:sendToPlayer(self)
+ msg:delete()
+end
+
+function Player.sendBoostiaryMilestonesData(self)
+ local msg = NetworkMessage()
+ msg:addByte(0x61)
+
+ -- BANE kills milestone
+ msg:addU16(CONST.KILLS.BANE.PROWESS)
+ msg:addU16(CONST.KILLS.BANE.EXPERTISE)
+ msg:addU16(CONST.KILLS.BANE.MASTERY)
+
+ -- ARCHFOE kills milestone
+ msg:addU16(CONST.KILLS.ARCHFOE.PROWESS)
+ msg:addU16(CONST.KILLS.ARCHFOE.EXPERTISE)
+ msg:addU16(CONST.KILLS.ARCHFOE.MASTERY)
+
+ -- NEMESIS kills milestone
+ msg:addU16(CONST.KILLS.NEMESIS.PROWESS)
+ msg:addU16(CONST.KILLS.NEMESIS.EXPERTISE)
+ msg:addU16(CONST.KILLS.NEMESIS.MASTERY)
+
+ -- BANE milestone points
+ msg:addU16(CONST.POINTS.BANE.PROWESS)
+ msg:addU16(CONST.POINTS.BANE.EXPERTISE)
+ msg:addU16(CONST.POINTS.BANE.MASTERY)
+
+ -- ARCHFOE milestone points
+ msg:addU16(CONST.POINTS.ARCHFOE.PROWESS)
+ msg:addU16(CONST.POINTS.ARCHFOE.EXPERTISE)
+ msg:addU16(CONST.POINTS.ARCHFOE.MASTERY)
+
+ -- NEMESIS milestone points
+ msg:addU16(CONST.POINTS.NEMESIS.PROWESS)
+ msg:addU16(CONST.POINTS.NEMESIS.EXPERTISE)
+ msg:addU16(CONST.POINTS.NEMESIS.MASTERY)
+
+ msg:sendToPlayer(self)
+ msg:delete()
+end
+
+-- Private api but require some additional Player methods to be loaded already
+local function addDailyBoostedBossToNetworkMessage(player, msg)
+ if not todayBoostedBoss then
+ msg:addByte(0)
+ msg:addU32(0)
+ return true
+ end
+
+ msg:addByte(1)
+ msg:addU32(todayBoostedBoss.id)
+ msg:addByte(todayBoostedBoss.bossType)
+ msg:addU32(player:getBosstiaryKills(todayBoostedBoss.id))
+ msg:addU16(player:getBosstiaryBonusValue(CONST.SLOT.THIRD))
+ msg:addByte(CONST.TODAY_BOOSTED_BOSS_KILL_COUNT)
+ msg:addByte(todayBoostedBoss.bossType)
+ msg:addU32(0) -- we can't remove boss from this slot, remove cost equal 0
+ msg:addByte(0)
+ return true
+end
+
+-- Private api but require some additional Player methods to be loaded already
+local function addSlotDataToNetworkMessage(player, msg, slot)
+ if not isValidSlot(slot) then
+ return false
+ end
+
+ if not player:isBosstiarySlotUnlocked(slot) then
+ msg:addByte(0)
+ msg:addU32(0)
+ return true
+ end
+
+ local bossId = player:getBosstiarySlotBoss(slot)
+ msg:addByte(1)
+ msg:addU32(bossId)
+ if bossId ~= 0 then
+ local bossData = Bosstiary.getBossById(bossId)
+ msg:addByte(bossData.bossType)
+ msg:addU32(player:getBosstiaryKills(bossId))
+ msg:addU16(player:getBosstiaryBonusValue(slot))
+ msg:addByte(0) -- kill bonus, not used
+ msg:addByte(bossData.bossType)
+ msg:addU32(player:getBosstiaryRemoveCost())
+ local isDailyBoosted = todayBoostedBoss.id == bossId
+ -- to ensure we push 0 or 1 to msg
+ msg:addByte(isDailyBoosted and 1 or 0)
+ return true
+ end
+
+ return true
+end
+
+-- Private api but require some additional Player methods to be loaded already
+local function addUnlockedBossesToNetworkMessage(player, msg)
+ local bufferPos = msg:len()
+ msg:skipBytes(2)
+ local unlockedCount = 0
+ for _, bossData in pairs(bossesByName) do
+ local isUnlocked = isBossUnlocked(bossData.id, player:getBosstiaryKills(bossData.id))
+ if isUnlocked then
+ unlockedCount = unlockedCount + 1
+ msg:addU32(bossData.id)
+ msg:addByte(bossData.bossType)
+ end
+ end
+ msg:seek(bufferPos)
+ msg:addU16(unlockedCount)
+
+ return true
+end
+
+function Player.sendBoostiarySlotsData(self)
+ local msg = NetworkMessage()
+ msg:addByte(0x62)
+ msg:addU32(self:getBosstiaryPoints())
+ msg:addU32(self:getBosstiaryNextLevelPoints())
+ msg:addU16(self:getBosstiaryBonusValue(-1)) -- current bonus excluding boss bonus
+ msg:addU16(self:getBosstiaryNextLevelBonusValue())
+
+ -- Left slot
+ local result = addSlotDataToNetworkMessage(self, msg, CONST.SLOT.FIRST)
+ if result == false then
+ msg:delete()
+ return
+ end
+
+ -- Right slot
+ result = addSlotDataToNetworkMessage(self, msg, CONST.SLOT.SECOND)
+ if result == false then
+ msg:delete()
+ return
+ end
+
+ -- Mid slot
+ result = addDailyBoostedBossToNetworkMessage(self, msg)
+ if result == false then
+ msg:delete()
+ return
+ end
+
+ msg:addByte(1)
+ addUnlockedBossesToNetworkMessage(self, msg)
+ msg:sendToPlayer(self)
+ msg:delete()
+end
diff --git a/data/lib/core/feature12plus/charms.lua b/data/lib/core/feature12plus/charms.lua
new file mode 100644
index 0000000000..ea14ea73bc
--- /dev/null
+++ b/data/lib/core/feature12plus/charms.lua
@@ -0,0 +1,47 @@
+local charmStatus = {
+ UNLOCKED = 1,
+ LOCKED = 0,
+}
+
+function Player.isCharmUnlocked(self, charmId)
+ if (self:getStorageValue(PlayerStorageKeys.charmsUnlocked + charmId) or 0) == charmStatus.UNLOCKED then
+ return charmStatus.UNLOCKED
+ end
+ return charmStatus.LOCKED
+end
+
+function Player.addCharmPoints(self, value)
+ local points = self:getCharmPoints() or 0
+ return self:setStorageValue(PlayerStorageKeys.charmPoints, points + value)
+end
+
+function Player.removeCharmPoints(self, value)
+ local points = self:getCharmPoints() or 0
+
+ if points < value then
+ return false
+ end
+ return self:setStorageValue(PlayerStorageKeys.charmPoints, points - value)
+end
+
+function Player.unlockCharm(self, charmId)
+ self:setStorageValue(PlayerStorageKeys.charmsUnlocked + charmId, charmStatus.UNLOCKED)
+end
+
+function Player.removeCharm(self, charmId)
+ self:setStorageValue(PlayerStorageKeys.charmsUnlocked + charmId, charmStatus.LOCKED)
+end
+
+function Player.setCharmMonster(self, charmId, raceId)
+ if self:isCharmUnlocked(charmId) == charmStatus.LOCKED then
+ return false
+ end
+ return self:setStorageValue(PlayerStorageKeys.charmsMonster + charmId, raceId)
+end
+
+function Player.getCharmPoints(self)
+ return math.max(0, self:getStorageValue(PlayerStorageKeys.charmPoints) or 0)
+end
+function Player.getCharmMonster(self, charmId)
+ return math.max(0, self:getStorageValue(PlayerStorageKeys.charmsMonster + charmId) or 0)
+end
\ No newline at end of file
diff --git a/data/lib/core/feature12plus/feature12plus.lua b/data/lib/core/feature12plus/feature12plus.lua
new file mode 100644
index 0000000000..4c34f9954e
--- /dev/null
+++ b/data/lib/core/feature12plus/feature12plus.lua
@@ -0,0 +1,2 @@
+dofile('data/lib/core/feature12plus/bosstiary.lua')
+dofile('data/lib/core/feature12plus/charms.lua')
\ No newline at end of file
diff --git a/data/lib/core/player.lua b/data/lib/core/player.lua
index 65c36c6150..a3566f0171 100644
--- a/data/lib/core/player.lua
+++ b/data/lib/core/player.lua
@@ -526,6 +526,11 @@ function Player.addBestiaryKills(self, raceId)
break
end
end
+
+ if kills < bestiaryInfo.mastery and newKills >= bestiaryInfo.mastery then
+ self:addCharmPoints(bestiaryInfo.charmPoints)
+ end
+
return self:setBestiaryKills(raceId, newKills)
end
diff --git a/data/lib/core/storages.lua b/data/lib/core/storages.lua
index bd67ee9d97..c8085fbb8c 100644
--- a/data/lib/core/storages.lua
+++ b/data/lib/core/storages.lua
@@ -43,4 +43,19 @@ PlayerStorageKeys = {
-- Bestiary:
bestiaryKillsBase = 400000,
+
+ -- Charms: 410000 to 410201
+ charmPoints = 410000,
+ charmsMonster = 410001,
+ charmsUnlocked = 410101,
+
+ -- Bosstiary: 430000 to 450006
+ bosstiaryKillsBase = 430000,
+ bosstiaryCooldownsBase = 440000,
+ bosstiaryPoints = 450000,
+ bosstiarySlot1 = 450001,
+ bosstiarySlot2 = 450002,
+ bosstiaryDay = 450004,
+ bosstiaryTodayRemoveDate = 450005,
+ bosstiaryTodayRemoveCount = 450006,
}
diff --git a/data/scripts/creaturescripts/player/bestiary_kills.lua b/data/scripts/creaturescripts/player/bestiary_kills.lua
index 4819aee4aa..76b6a6393e 100644
--- a/data/scripts/creaturescripts/player/bestiary_kills.lua
+++ b/data/scripts/creaturescripts/player/bestiary_kills.lua
@@ -37,6 +37,7 @@ function creatureEvent.onKill(player, target)
for _, killer in pairs(getKillersForBestiary(monster)) do
killer:addBestiaryKills(raceId)
end
+
return true
end
diff --git a/data/scripts/feature12plus/bosstiary.lua b/data/scripts/feature12plus/bosstiary.lua
new file mode 100644
index 0000000000..39e8ace184
--- /dev/null
+++ b/data/scripts/feature12plus/bosstiary.lua
@@ -0,0 +1,129 @@
+local handlerList = PacketHandler(0xAE)
+function handlerList.onReceive(player, msg)
+ player:sendBoostiaryMilestonesData()
+ player:sendBosstiaryList()
+end
+handlerList:register()
+
+local handlerSlot = PacketHandler(0xAF)
+function handlerSlot.onReceive(player, msg)
+ player:sendBoostiaryMilestonesData()
+ player:sendBoostiarySlotsData()
+ player:sendResourceBalance(RESOURCE_BANK_BALANCE, player:getBankBalance())
+ player:sendResourceBalance(RESOURCE_GOLD_EQUIPPED, player:getMoney())
+end
+handlerSlot:register()
+
+local handlerSet = PacketHandler(0xB0)
+function handlerSet.onReceive(player, msg)
+ local slot = msg:getByte()
+ local bossId = msg:getU32()
+ local isValidBoss = player:isValidBoostiaryBoss(bossId)
+ local isGoldRemoved = true
+ if bossId == 0 then
+ isGoldRemoved = player:removeTotalMoney(player:getBosstiaryRemoveCost())
+ player:incrementBosstiaryRemoveCounter()
+ end
+
+ if isValidBoss == true and isGoldRemoved == true then
+ player:sendResourceBalance(RESOURCE_BANK_BALANCE, player:getBankBalance())
+ player:sendResourceBalance(RESOURCE_GOLD_EQUIPPED, player:getMoney())
+ player:setBosstiarySlotBoss(slot, bossId)
+ player:sendBoostiarySlotsData()
+ else
+ player:popupFYI("You cannot set this boss to the selected slot!")
+ end
+
+end
+handlerSet:register()
+
+local playerCharm = CreatureEvent("___")
+function playerCharm.onLogin(player)
+ player:sendTextMessage(MESSAGE_STATUS_DEFAULT,
+ string.format("%s %s\n%s", "Today's boosted boss:",
+ Bosstiary.getBossById(Bosstiary.getTodayBoostedBoss().id).name,
+ "Boosted bosses contain more loot and count more kills for your Bosstiary."))
+ player:registerEvent("Bosstiary_onKill")
+ player:resetBestiaryRemoveCountIfNeeded()
+ player:setBosstiaryLastRemoveDate()
+ return true
+end
+playerCharm:register()
+
+local creatureevent = CreatureEvent("Bosstiary_onKill")
+function creatureevent.onKill(player, target)
+ local bossData = Bosstiary.getBossByName(target:getName())
+ if not bossData then
+ return true
+ end
+
+ local todayBoostedBossId = Bosstiary.getTodayBoostedBoss().id
+ local todayBoostedBossName = Bosstiary.getBossById(todayBoostedBossId).name
+ local killers = target:getKillers(true)
+ if todayBoostedBossName == target:getName() then
+ for _, killer in ipairs(killers) do
+ killer:addBosstiaryKills(todayBoostedBossId, Bosstiary.getConst().TODAY_BOOSTED_BOSS_KILL_COUNT)
+ end
+ else
+ for _, killer in ipairs(killers) do
+ killer:addBosstiaryKills(bossData.id, 1)
+ end
+ end
+ return true
+end
+creatureevent:register()
+
+local function isEqItem(item)
+ local it = ItemType(item.itemId)
+ if it:isWeapon() or it:isHelmet() or it:isArmor() or it:isLegs() or it:isBoots() or it:isQuiver() or it:isShield() or
+ it:isNecklace() or it:isRing() or it:isTrinket() then
+ return true
+ end
+ return false
+end
+
+local event = Event()
+event.onDropLoot = function(self, corpse)
+ if configManager.getNumber(configKeys.RATE_LOOT) == 0 then
+ return
+ end
+
+ local player = Player(corpse:getCorpseOwner())
+ local mType = self:getType()
+ local doCreateLoot = false
+
+ if not player or player:getStamina() > 840 then
+ doCreateLoot = true
+ end
+
+ local bossData = Bosstiary.getBossByName(self:getName())
+ if not bossData then
+ return
+ end
+
+ local bonusRollChance = player:getBosstiaryBonusValue()
+ if bossData.id == Bosstiary.getTodayBoostedBoss().id then
+ bonusRollChance = Bosstiary.getConst().TODAY_BOOSTED_BOSS_KILL_BONUS
+ end
+
+ while bonusRollChance > 0 do
+ local roll = math.random(1, 100)
+ if roll <= bonusRollChance then
+ if doCreateLoot then
+ local monsterLoot = mType:getLoot()
+ for i = 1, #monsterLoot do
+ if isEqItem(monsterLoot[i]) then
+ local item = corpse:createLootItem(monsterLoot[i], 1)
+ if not item then
+ print("[Warning] DropLoot: Could not add loot item to corpse.")
+ end
+ end
+ end
+ end
+ end
+
+ bonusRollChance = bonusRollChance - 100
+ end
+end
+
+event:register(-1)
diff --git a/data/scripts/feature12plus/charms.lua b/data/scripts/feature12plus/charms.lua
new file mode 100644
index 0000000000..a409f10c68
--- /dev/null
+++ b/data/scripts/feature12plus/charms.lua
@@ -0,0 +1,406 @@
+local config = {
+ faccLimit = 2,
+ premiumLimit = 6,
+ premiumDiscount = 0.75, -- percent multipler
+ charmDataSize = 0,
+ charmCostPerLevel = 100
+}
+
+local ACTION_TYPE = {
+ UNLOCK_RUNE = 0,
+ SET_CREATURE = 1,
+ REMOVE_CREATURE = 2
+}
+
+CHARMS_TYPE = {
+ WOUND = 0,
+ ENFLAME = 1,
+ POISON = 2,
+ FREEZE = 3,
+ ZAP = 4,
+ CURSE = 5,
+ CRIPPLE = 6,
+ PARRY = 7,
+ DODGE = 8,
+ ADRENALINE_BOOST = 9,
+ NUMB = 10,
+ CLEANS = 11,
+ BLSEE = 12,
+ SCAVANGE = 13,
+ GUT = 14,
+ LOW_BLOW = 15,
+ DIVINE_WRATH = 16,
+ VAMPIRIC_EMBRACE = 17,
+ VOID_CALL = 18,
+
+ LAST = 18
+}
+
+CHARMS_DATA = {
+ [CHARMS_TYPE.WOUND] = {
+ id = CHARMS_TYPE.WOUND,
+ name = "Wound",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Physical Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_HITAREA,
+ cost = 600,
+ damageType = COMBAT_PHYSICALDAMAGE
+ },
+ [CHARMS_TYPE.ENFLAME] = {
+ id = CHARMS_TYPE.ENFLAME,
+ name = "Enflame",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Fire Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_HITBYFIRE,
+ cost = 1000,
+ damageType = COMBAT_FIREDAMAGE
+ },
+ [CHARMS_TYPE.POISON] = {
+ id = CHARMS_TYPE.POISON,
+ name = "Poison",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Earth Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_GREEN_RINGS,
+ cost = 600,
+ damageType = COMBAT_EARTHDAMAGE
+ },
+ [CHARMS_TYPE.FREEZE] = {
+ id = CHARMS_TYPE.FREEZE,
+ name = "Freez",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Ice Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_ICEATTACK,
+ cost = 800,
+ damageType = COMBAT_ICEDAMAGE
+ },
+ [CHARMS_TYPE.ZAP] = {
+ id = CHARMS_TYPE.ZAP,
+ name = "Zap",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Energy Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_ENERGYHIT,
+ cost = 800,
+ damageType = COMBAT_ENERGYDAMAGE
+ },
+ [CHARMS_TYPE.CURSE] = {
+ id = CHARMS_TYPE.CURSE,
+ name = "Curse",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Death Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_SMALLCLOUDS,
+ cost = 900,
+ damageType = COMBAT_DEATHDAMAGE
+ },
+ [CHARMS_TYPE.DIVINE_WRATH] = {
+ id = CHARMS_TYPE.DIVINE_WRATH,
+ name = "Divine Wrath",
+ description = "Triggers on a creature with a certain chance and deals 5% of its initial hit points as Holy Damage once.",
+ percentage = 5,
+ chance = 10,
+ effect = CONST_ME_HOLYDAMAGE,
+ cost = 1500,
+ damageType = COMBAT_HOLYDAMAGE
+ },
+ [CHARMS_TYPE.DODGE] = {
+ id = CHARMS_TYPE.DODGE,
+ name = "Dodge",
+ description = "Dodges an attack with a certain chance without taking any damage at all.",
+ chance = 10,
+ effect = 231,
+ cost = 1000
+ },
+}
+
+local function getCharmRemoveCost(player)
+ if player:isPremium() then
+ return config.charmCostPerLevel * player:getLevel() * config.premiumDiscount
+ end
+ return config.charmCostPerLevel * player:getLevel()
+end
+
+local function getCompletedBestiary(player)
+ local bestiaryClasses = Game.getBestiary()
+ local unlockedRaces = {}
+ for _, class in pairs(bestiaryClasses) do
+ for _, monsterType in ipairs(class.monsterTypes) do
+ local info = monsterType:getBestiaryInfo()
+ if info then
+ if info.raceId and player:getStorageValue(PlayerStorageKeys.bestiaryKillsBase + info.raceId) >=
+ info.mastery then
+ table.insert(unlockedRaces, info.raceId)
+ end
+ end
+ end
+ end
+ return unlockedRaces
+end
+
+local function getTableLength(tbl)
+ local getN = 0
+ for n in pairs(tbl) do
+ getN = getN + 1
+ end
+ return getN
+end
+
+do
+ config.charmDataSize = getTableLength(CHARMS_DATA)
+end
+
+local function getMaxCharmCreatures(player)
+ if player:isPremium() then
+ return config.premiumLimit
+ else
+ return config.faccLimit
+ end
+end
+
+function sendCharmData(player)
+ local msg = NetworkMessage()
+ msg:addByte(0xD8)
+ msg:addU32(player:getCharmPoints())
+
+ msg:addByte(config.charmDataSize)
+ for i = 0, CHARMS_TYPE.LAST, 1 do
+ local charmData = CHARMS_DATA[i]
+ if charmData then
+ msg:addByte(charmData.id)
+ msg:addString(charmData.name)
+ msg:addString(charmData.description)
+ msg:addByte(2)
+ msg:addU16(charmData.cost)
+ msg:addByte(player:isCharmUnlocked(charmData.id))
+ local raceIdAssigned = player:getCharmMonster(charmData.id)
+ if raceIdAssigned == 0 then
+ msg:addByte(0)
+ else
+ msg:addByte(1)
+ msg:addU16(raceIdAssigned)
+ msg:addU32(getCharmRemoveCost(player))
+ end
+ end
+ end
+
+ msg:addByte(getMaxCharmCreatures(player))
+
+ local typeIds = getCompletedBestiary(player)
+ msg:addU16(#typeIds)
+ for _, value in ipairs(typeIds) do
+ msg:addU16(value)
+ end
+ msg:sendToPlayer(player)
+ msg:delete()
+end
+
+local function buyCharmRune(player, runeId)
+ if not CHARMS_DATA[runeId] then
+ return false
+ end
+
+ if CHARMS_DATA[runeId].cost <= player:getCharmPoints() then
+ player:unlockCharm(runeId)
+ player:popupFYI("You successfully unlocked '" .. CHARMS_DATA[runeId].name .. "' for " ..
+ CHARMS_DATA[runeId].cost .. " charm points.")
+ player:removeCharmPoints(CHARMS_DATA[runeId].cost)
+ else
+ player:popupFYI("You don't have enough charm points to unlock this rune")
+ end
+
+ return true
+end
+
+local function isCharmedCreaturesLimitReached(player, count)
+ if getMaxCharmCreatures(player) > count then
+ return false
+ end
+
+ return true
+end
+
+local function isCharmedMonsterAlreadyCharmed(player, raceId)
+ for i = 0, CHARMS_TYPE.LAST, 1 do
+ if CHARMS_DATA[i] then
+ if player:getCharmMonster(CHARMS_DATA[i].id) == raceId then
+ return true
+ end
+ end
+ end
+
+ return false
+end
+
+local function setCharmCreature(player, runeId, raceId)
+ local count = 0
+ for i = 0, CHARMS_TYPE.LAST, 1 do
+ if CHARMS_DATA[i] then
+ if player:getCharmMonster(CHARMS_DATA[i].id) > 0 then
+ count = count + 1
+ end
+ end
+ end
+
+ if not CHARMS_DATA[runeId] or not MonsterType(raceId) then
+ print(">> setCharmCreature: invalid request")
+ return
+ end
+
+ if CHARMS_DATA[runeId].id == CHARMS_TYPE.SCAVANGE then
+ player:popupFYI("This charm is always active, monster not required.")
+ return
+ end
+
+ if isCharmedCreaturesLimitReached(player, count) then
+ player:popupFYI("You don't have any charm slots available.")
+ return
+ end
+
+ if isCharmedMonsterAlreadyCharmed(player, raceId) then
+ player:popupFYI("Charm failed, " .. MonsterType(raceId):getName() .. " is already charmed.")
+ return
+ end
+
+ player:setCharmMonster(runeId, raceId)
+ player:popupFYI("Charm '" .. CHARMS_DATA[runeId].name .. "' has been successfully set to " ..
+ MonsterType(raceId):getName())
+end
+
+local function removeCharmCreature(player, runeId)
+ if not CHARMS_DATA[runeId] then
+ print(">> removeCharmCreature: invalid request")
+ return
+ end
+
+ if player:getTotalMoney() < getCharmRemoveCost(player) then
+ player:popupFYI("You don't have enough money.")
+ return
+ end
+
+ player:popupFYI("Charm '" .. CHARMS_DATA[runeId].name .. "' has been successfully cleared.")
+ player:removeTotalMoney(getCharmRemoveCost(player))
+ player:setCharmMonster(runeId, 0)
+end
+
+local handlerBuy = PacketHandler(0xE4)
+function handlerBuy.onReceive(player, msg)
+ local runeId = msg:getByte()
+ local action = msg:getByte()
+ local raceId = msg:getU16()
+
+ if action == ACTION_TYPE.UNLOCK_RUNE then
+ buyCharmRune(player, runeId)
+ elseif action == ACTION_TYPE.SET_CREATURE then
+ setCharmCreature(player, runeId, raceId)
+ elseif action == ACTION_TYPE.REMOVE_CREATURE then
+ removeCharmCreature(player, runeId)
+ end
+ sendCharmData(player)
+ player:sendResourceBalance(RESOURCE_BANK_BALANCE, player:getBankBalance())
+ player:sendResourceBalance(RESOURCE_GOLD_EQUIPPED, player:getMoney())
+ player:sendResourceBalance(RESOURCE_CHARM_POINTS, player:getCharmPoints())
+end
+handlerBuy:register()
+
+local combatCharms = {CHARMS_TYPE.WOUND, CHARMS_TYPE.ENFLAME, CHARMS_TYPE.POISON, CHARMS_TYPE.FREEZE, CHARMS_TYPE.ZAP,
+ CHARMS_TYPE.CURSE, CHARMS_TYPE.DIVINE_WRATH}
+
+local charmCombat = Combat()
+charmCombat:setParameter(COMBAT_PARAM_USECHARGES, 1)
+
+local isCharmTrigerred = false
+
+local charmDamage = CreatureEvent("CharmMonsterDamageTrigger")
+function charmDamage.onHealthChange(creature, attacker, primaryDamage, primaryType, secondaryDamage, secondaryType,
+ origin)
+
+ if isCharmTrigerred == true then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ if not attacker or not creature then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ if not (creature:isMonster() and attacker:isPlayer()) then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ local bestiaryInfo = creature:getType():getBestiaryInfo()
+ if not bestiaryInfo then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ if bestiaryInfo.raceId == 0 then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ local charm = nil
+ for index, value in ipairs(combatCharms) do
+ if attacker:getCharmMonster(CHARMS_DATA[value].id) == bestiaryInfo.raceId then
+ charm = CHARMS_DATA[value]
+ break
+ end
+ end
+
+ if not charm then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ if math.random(0, 100) <= charm.chance then
+ isCharmTrigerred = true
+ local damage = math.floor(creature:getMaxHealth() / 100 * charm.percentage)
+ charmCombat:setParameter(COMBAT_PARAM_TYPE, charm.damageType)
+ charmCombat:setParameter(COMBAT_PARAM_EFFECT, charm.effect)
+ charmCombat:setFormula(COMBAT_FORMULA_DAMAGE, -damage, 0, -damage, 0)
+ charmCombat:execute(attacker, Variant(creature:getId()))
+ isCharmTrigerred = false
+ end
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+end
+charmDamage:register()
+
+local charmDamage = CreatureEvent("CharmPlayerDamageReceived")
+function charmDamage.onHealthChange(creature, attacker, primaryDamage, primaryType, secondaryDamage, secondaryType, origin)
+ if not attacker then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ if not (creature:isPlayer() and attacker:isMonster()) then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ local bestiaryInfo = attacker:getType():getBestiaryInfo()
+ if not bestiaryInfo then
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+ end
+
+ local charm = CHARMS_DATA[CHARMS_TYPE.DODGE]
+
+ if math.random(0, 100) <= charm.chance and creature:getCharmMonster(charm.id) == bestiaryInfo.raceId and bestiaryInfo.raceId ~= 0 then
+ creature:getPosition():sendMagicEffect(charm.effect)
+ creature:sendTextMessage(MESSAGE_DAMAGE_RECEIVED, "You blocked damage from " .. attacker:getDescription())
+ return 0, primaryType, 0, secondaryType
+ end
+
+ return primaryDamage, primaryType, secondaryDamage, secondaryType
+end
+charmDamage:register()
+
+local playerCharm = CreatureEvent("CharmPlayerDamageReceivedLogin")
+function playerCharm.onLogin(player)
+ player:registerEvent("CharmPlayerDamageReceived")
+ return true
+end
+playerCharm:register()
+
+local ec = EventCallback
+ec.onSpawn = function(monster, position, startup, artificial)
+ monster:registerEvent("CharmMonsterDamageTrigger")
+ return true
+end
+ec:register()
\ No newline at end of file
diff --git a/data/scripts/network/bestiary_classes.lua b/data/scripts/network/bestiary_classes.lua
index 62aab8b386..1a1361e544 100644
--- a/data/scripts/network/bestiary_classes.lua
+++ b/data/scripts/network/bestiary_classes.lua
@@ -12,6 +12,11 @@ end
local handler = PacketHandler(0xE1)
function handler.onReceive(player)
+ sendCharmData(player)
+ player:sendResourceBalance(RESOURCE_BANK_BALANCE, player:getBankBalance())
+ player:sendResourceBalance(RESOURCE_GOLD_EQUIPPED, player:getMoney())
+ player:sendResourceBalance(RESOURCE_CHARM_POINTS, player:getCharmPoints())
+
local bestiaryClasses = Game.getBestiary()
local msg = NetworkMessage()
msg:addByte(0xD5)
diff --git a/src/const.h b/src/const.h
index c7dc5cfc55..99225b5652 100644
--- a/src/const.h
+++ b/src/const.h
@@ -608,6 +608,7 @@ enum ResourceTypes_t : uint8_t
RESOURCE_PREY_WILDCARDS = 0x0A,
RESOURCE_DAILYREWARD_STREAK = 0x14,
RESOURCE_DAILYREWARD_JOKERS = 0x15,
+ RESOURCE_CHARM_POINTS = 0x1E, // u32
};
enum PlayerFlags : uint64_t
diff --git a/src/luascript.cpp b/src/luascript.cpp
index e7a5e88ef1..009626cb7a 100644
--- a/src/luascript.cpp
+++ b/src/luascript.cpp
@@ -2222,6 +2222,13 @@ void LuaScriptInterface::registerFunctions()
registerEnum(L, ZONE_NOLOGOUT);
registerEnum(L, ZONE_NORMAL);
+ registerEnum(L, RESOURCE_BANK_BALANCE);
+ registerEnum(L, RESOURCE_GOLD_EQUIPPED);
+ registerEnum(L, RESOURCE_PREY_WILDCARDS);
+ registerEnum(L, RESOURCE_DAILYREWARD_STREAK);
+ registerEnum(L, RESOURCE_DAILYREWARD_JOKERS);
+ registerEnum(L, RESOURCE_CHARM_POINTS);
+
registerEnum(L, MAX_LOOTCHANCE);
registerEnum(L, SPELL_INSTANT);
diff --git a/src/protocolgame.cpp b/src/protocolgame.cpp
index 5955ad170d..92483c44d8 100644
--- a/src/protocolgame.cpp
+++ b/src/protocolgame.cpp
@@ -2072,7 +2072,11 @@ void ProtocolGame::sendResourceBalance(const ResourceTypes_t resourceType, uint6
NetworkMessage msg;
msg.addByte(0xEE);
msg.addByte(resourceType);
- msg.add(amount);
+ if (resourceType == RESOURCE_CHARM_POINTS) {
+ msg.add(amount);
+ } else {
+ msg.add(amount);
+ }
writeToOutputBuffer(msg);
}