diff --git a/common/database/database_update_manifest.cpp b/common/database/database_update_manifest.cpp index 63f3ba35b3..6b9d099735 100644 --- a/common/database/database_update_manifest.cpp +++ b/common/database/database_update_manifest.cpp @@ -5758,6 +5758,17 @@ ALTER TABLE `inventory_snapshots` ALTER TABLE `character_exp_modifiers` MODIFY COLUMN `aa_modifier` float NOT NULL DEFAULT 1.0 AFTER `instance_version`, MODIFY COLUMN `exp_modifier` float NOT NULL DEFAULT 1.0 AFTER `aa_modifier`; +)" + }, + ManifestEntry{ + .version = 9285, + .description = "2024_10_15_npc_types_multiquest_enabled.sql", + .check = "SHOW COLUMNS FROM `npc_types` LIKE 'multiquest_enabled'", + .condition = "empty", + .match = "", + .sql = R"( +ALTER TABLE `npc_types` +ADD COLUMN `multiquest_enabled` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `is_parcel_merchant`; )" } // -- template; copy/paste this when you need to create a new entry diff --git a/common/eqemu_logsys.cpp b/common/eqemu_logsys.cpp index c4c14f82f5..f502d79518 100644 --- a/common/eqemu_logsys.cpp +++ b/common/eqemu_logsys.cpp @@ -102,6 +102,8 @@ EQEmuLogSys *EQEmuLogSys::LoadLogSettingsDefaults() log_settings[Logs::QuestErrors].log_to_console = static_cast(Logs::General); log_settings[Logs::EqTime].log_to_console = static_cast(Logs::General); log_settings[Logs::EqTime].log_to_gmsay = static_cast(Logs::General); + log_settings[Logs::NpcHandin].log_to_console = static_cast(Logs::General); + log_settings[Logs::NpcHandin].log_to_gmsay = static_cast(Logs::General); /** * RFC 5424 diff --git a/common/eqemu_logsys.h b/common/eqemu_logsys.h index 8bf474f349..fb64b7d4e6 100644 --- a/common/eqemu_logsys.h +++ b/common/eqemu_logsys.h @@ -142,6 +142,7 @@ namespace Logs { EqTime, Corpses, XTargets, + NpcHandin, MaxCategoryID /* Don't Remove this */ }; @@ -242,7 +243,8 @@ namespace Logs { "Zoning", "EqTime", "Corpses", - "XTargets" + "XTargets", + "NpcHandin" }; } diff --git a/common/eqemu_logsys_log_aliases.h b/common/eqemu_logsys_log_aliases.h index 10c9cd98a2..f34ddf4a71 100644 --- a/common/eqemu_logsys_log_aliases.h +++ b/common/eqemu_logsys_log_aliases.h @@ -361,7 +361,6 @@ OutF(LogSys, Logs::Detail, Logs::PacketServerClient, __FILE__, __func__, __LINE__, message, ##__VA_ARGS__);\ } while (0) - #define LogLoginserver(message, ...) do {\ if (LogSys.IsLogEnabled(Logs::General, Logs::Loginserver))\ OutF(LogSys, Logs::General, Logs::Loginserver, __FILE__, __func__, __LINE__, message, ##__VA_ARGS__);\ @@ -844,6 +843,16 @@ OutF(LogSys, Logs::Detail, Logs::XTargets, __FILE__, __func__, __LINE__, message, ##__VA_ARGS__);\ } while (0) +#define LogNpcHandin(message, ...) do {\ + if (LogSys.IsLogEnabled(Logs::General, Logs::NpcHandin))\ + OutF(LogSys, Logs::General, Logs::NpcHandin, __FILE__, __func__, __LINE__, message, ##__VA_ARGS__);\ +} while (0) + +#define LogNpcHandinDetail(message, ...) do {\ + if (LogSys.IsLogEnabled(Logs::Detail, Logs::NpcHandin))\ + OutF(LogSys, Logs::Detail, Logs::NpcHandin, __FILE__, __func__, __LINE__, message, ##__VA_ARGS__);\ +} while (0) + #define Log(debug_level, log_category, message, ...) do {\ if (LogSys.IsLogEnabled(debug_level, log_category))\ LogSys.Out(debug_level, log_category, __FILE__, __func__, __LINE__, message, ##__VA_ARGS__);\ diff --git a/common/events/player_event_discord_formatter.cpp b/common/events/player_event_discord_formatter.cpp index 578ec672bc..d593cb5a54 100644 --- a/common/events/player_event_discord_formatter.cpp +++ b/common/events/player_event_discord_formatter.cpp @@ -714,6 +714,18 @@ std::string PlayerEventDiscordFormatter::FormatNPCHandinEvent( h.charges > 1 ? fmt::format(" Charges: {}", h.charges) : "", h.attuned ? " (Attuned)" : "" ); + + for (int i = 0; i < h.augment_ids.size(); i++) { + if (!Strings::EqualFold(h.augment_names[i], "None")) { + const uint8 slot_id = (i + 1); + handin_items_info += fmt::format( + "Augment {}: {} ({})\n", + slot_id, + h.augment_names[i], + h.augment_ids[i] + ); + } + } } } @@ -727,6 +739,18 @@ std::string PlayerEventDiscordFormatter::FormatNPCHandinEvent( r.charges > 1 ? fmt::format(" Charges: {}", r.charges) : "", r.attuned ? " (Attuned)" : "" ); + + for (int i = 0; i < r.augment_ids.size(); i++) { + if (!Strings::EqualFold(r.augment_names[i], "None")) { + const uint8 slot_id = (i + 1); + handin_items_info += fmt::format( + "Augment {}: {} ({})\n", + slot_id, + r.augment_names[i], + r.augment_ids[i] + ); + } + } } } diff --git a/common/item_data.cpp b/common/item_data.cpp index 6dcb1f5778..bcc311a0f8 100644 --- a/common/item_data.cpp +++ b/common/item_data.cpp @@ -220,6 +220,27 @@ bool EQ::ItemData::IsType1HWeapon() const return ((ItemType == item::ItemType1HBlunt) || (ItemType == item::ItemType1HSlash) || (ItemType == item::ItemType1HPiercing) || (ItemType == item::ItemTypeMartial)); } +bool EQ::ItemData::IsPetUsable() const +{ + if (ItemClass == item::ItemClassBag) { + return true; + } + + switch (ItemType) { + case item::ItemType1HBlunt: + case item::ItemType1HSlash: + case item::ItemType1HPiercing: + case item::ItemType2HBlunt: + case item::ItemType2HSlash: + case item::ItemTypeMartial: + case item::ItemTypeShield: + case item::ItemTypeArmor: + return true; + default: + return false; + } +} + bool EQ::ItemData::IsType2HWeapon() const { return ((ItemType == item::ItemType2HBlunt) || (ItemType == item::ItemType2HSlash) || (ItemType == item::ItemType2HPiercing)); diff --git a/common/item_data.h b/common/item_data.h index 449a867598..e4cad0e7a3 100644 --- a/common/item_data.h +++ b/common/item_data.h @@ -550,6 +550,7 @@ namespace EQ bool IsType1HWeapon() const; bool IsType2HWeapon() const; bool IsTypeShield() const; + bool IsPetUsable() const; bool IsQuestItem() const; static bool CheckLoreConflict(const ItemData* l_item, const ItemData* r_item); diff --git a/common/item_instance.cpp b/common/item_instance.cpp index 6aeb32223b..6462b1c489 100644 --- a/common/item_instance.cpp +++ b/common/item_instance.cpp @@ -1806,6 +1806,18 @@ std::vector EQ::ItemInstance::GetAugmentIDs() const return augments; } +std::vector EQ::ItemInstance::GetAugmentNames() const +{ + std::vector augment_names; + + for (uint8 slot_id = invaug::SOCKET_BEGIN; slot_id <= invaug::SOCKET_END; slot_id++) { + const auto augment = GetAugment(slot_id); + augment_names.push_back(augment ? augment->GetItem()->Name : "None"); + } + + return augment_names; +} + int EQ::ItemInstance::GetItemRegen(bool augments) const { int stat = 0; diff --git a/common/item_instance.h b/common/item_instance.h index 928a6aabc3..63081ef58d 100644 --- a/common/item_instance.h +++ b/common/item_instance.h @@ -309,6 +309,7 @@ namespace EQ int GetItemSkillsStat(EQ::skills::SkillType skill, bool augments = false) const; uint32 GetItemGuildFavor() const; std::vector GetAugmentIDs() const; + std::vector GetAugmentNames() const; static void AddGUIDToMap(uint64 existing_serial_number); static void ClearGUIDMap(); diff --git a/common/repositories/base/base_npc_types_repository.h b/common/repositories/base/base_npc_types_repository.h index 1eb12660bf..02632be481 100644 --- a/common/repositories/base/base_npc_types_repository.h +++ b/common/repositories/base/base_npc_types_repository.h @@ -148,6 +148,7 @@ class BaseNpcTypesRepository { int32_t faction_amount; uint8_t keeps_sold_items; uint8_t is_parcel_merchant; + uint8_t multiquest_enabled; }; static std::string PrimaryKey() @@ -287,6 +288,7 @@ class BaseNpcTypesRepository { "faction_amount", "keeps_sold_items", "is_parcel_merchant", + "multiquest_enabled", }; } @@ -422,6 +424,7 @@ class BaseNpcTypesRepository { "faction_amount", "keeps_sold_items", "is_parcel_merchant", + "multiquest_enabled", }; } @@ -591,6 +594,7 @@ class BaseNpcTypesRepository { e.faction_amount = 0; e.keeps_sold_items = 1; e.is_parcel_merchant = 0; + e.multiquest_enabled = 0; return e; } @@ -756,6 +760,7 @@ class BaseNpcTypesRepository { e.faction_amount = row[126] ? static_cast(atoi(row[126])) : 0; e.keeps_sold_items = row[127] ? static_cast(strtoul(row[127], nullptr, 10)) : 1; e.is_parcel_merchant = row[128] ? static_cast(strtoul(row[128], nullptr, 10)) : 0; + e.multiquest_enabled = row[129] ? static_cast(strtoul(row[129], nullptr, 10)) : 0; return e; } @@ -917,6 +922,7 @@ class BaseNpcTypesRepository { v.push_back(columns[126] + " = " + std::to_string(e.faction_amount)); v.push_back(columns[127] + " = " + std::to_string(e.keeps_sold_items)); v.push_back(columns[128] + " = " + std::to_string(e.is_parcel_merchant)); + v.push_back(columns[129] + " = " + std::to_string(e.multiquest_enabled)); auto results = db.QueryDatabase( fmt::format( @@ -1067,6 +1073,7 @@ class BaseNpcTypesRepository { v.push_back(std::to_string(e.faction_amount)); v.push_back(std::to_string(e.keeps_sold_items)); v.push_back(std::to_string(e.is_parcel_merchant)); + v.push_back(std::to_string(e.multiquest_enabled)); auto results = db.QueryDatabase( fmt::format( @@ -1225,6 +1232,7 @@ class BaseNpcTypesRepository { v.push_back(std::to_string(e.faction_amount)); v.push_back(std::to_string(e.keeps_sold_items)); v.push_back(std::to_string(e.is_parcel_merchant)); + v.push_back(std::to_string(e.multiquest_enabled)); insert_chunks.push_back("(" + Strings::Implode(",", v) + ")"); } @@ -1387,6 +1395,7 @@ class BaseNpcTypesRepository { e.faction_amount = row[126] ? static_cast(atoi(row[126])) : 0; e.keeps_sold_items = row[127] ? static_cast(strtoul(row[127], nullptr, 10)) : 1; e.is_parcel_merchant = row[128] ? static_cast(strtoul(row[128], nullptr, 10)) : 0; + e.multiquest_enabled = row[129] ? static_cast(strtoul(row[129], nullptr, 10)) : 0; all_entries.push_back(e); } @@ -1540,6 +1549,7 @@ class BaseNpcTypesRepository { e.faction_amount = row[126] ? static_cast(atoi(row[126])) : 0; e.keeps_sold_items = row[127] ? static_cast(strtoul(row[127], nullptr, 10)) : 1; e.is_parcel_merchant = row[128] ? static_cast(strtoul(row[128], nullptr, 10)) : 0; + e.multiquest_enabled = row[129] ? static_cast(strtoul(row[129], nullptr, 10)) : 0; all_entries.push_back(e); } @@ -1743,6 +1753,7 @@ class BaseNpcTypesRepository { v.push_back(std::to_string(e.faction_amount)); v.push_back(std::to_string(e.keeps_sold_items)); v.push_back(std::to_string(e.is_parcel_merchant)); + v.push_back(std::to_string(e.multiquest_enabled)); auto results = db.QueryDatabase( fmt::format( @@ -1894,6 +1905,7 @@ class BaseNpcTypesRepository { v.push_back(std::to_string(e.faction_amount)); v.push_back(std::to_string(e.keeps_sold_items)); v.push_back(std::to_string(e.is_parcel_merchant)); + v.push_back(std::to_string(e.multiquest_enabled)); insert_chunks.push_back("(" + Strings::Implode(",", v) + ")"); } diff --git a/common/ruletypes.h b/common/ruletypes.h index a428325271..8933e6b409 100644 --- a/common/ruletypes.h +++ b/common/ruletypes.h @@ -283,10 +283,9 @@ RULE_CATEGORY(Pets) RULE_REAL(Pets, AttackCommandRange, 150, "Range at which a pet will respond to attack commands") RULE_BOOL(Pets, UnTargetableSwarmPet, false, "Setting whether swarm pets should be targetable") RULE_REAL(Pets, PetPowerLevelCap, 10, "Maximum number of levels a player pet can go up with pet power") -RULE_BOOL(Pets, CanTakeNoDrop, false, "Setting whether anyone can give no-drop items to pets") -RULE_BOOL(Pets, CanTakeQuestItems, true, "Setting whether anyone can give quest items to pets") RULE_BOOL(Pets, LivelikeBreakCharmOnInvis, true, "Default: true will break charm on any type of invis (hide/ivu/iva/etc) false will only break if the pet can not see you (ex. you have an undead pet and cast IVU") RULE_BOOL(Pets, ClientPetsUseOwnerNameInLastName, true, "Disable this to keep client pet's last names from being owner_name's pet") +RULE_BOOL(Pets, CanTakeNoDrop, false, "Setting whether anyone can give no-drop items to pets") RULE_INT(Pets, PetTauntRange, 150, "Range at which a pet will taunt targets.") RULE_CATEGORY_END() @@ -657,8 +656,6 @@ RULE_BOOL(NPC, EnableNPCQuestJournal, false, "Setting whether the NPC Quest Jour RULE_INT(NPC, LastFightingDelayMovingMin, 10000, "Minimum time before mob goes home after all aggro loss (milliseconds)") RULE_INT(NPC, LastFightingDelayMovingMax, 20000, "Maximum time before mob goes home after all aggro loss (milliseconds)") RULE_BOOL(NPC, SmartLastFightingDelayMoving, true, "When true, mobs that started going home previously will do so again immediately if still on FD hate list") -RULE_BOOL(NPC, ReturnNonQuestNoDropItems, false, "Returns NO DROP items on NPC that don't have an EVENT_TRADE sub in their script") -RULE_BOOL(NPC, ReturnQuestItemsFromNonQuestNPCs, false, "Returns Quest items traded to NPCs that are not flagged as a Quest NPC") RULE_INT(NPC, StartEnrageValue, 9, " Percentage HP that an NPC will begin to enrage") RULE_BOOL(NPC, LiveLikeEnrage, false, "If set to true then only player controlled pets will enrage") RULE_BOOL(NPC, EnableMeritBasedFaction, false, "If set to true, faction will be given in the same way as experience (solo/group/raid)") diff --git a/common/spdat.cpp b/common/spdat.cpp index 3acac1c8b9..59a22aa4da 100644 --- a/common/spdat.cpp +++ b/common/spdat.cpp @@ -2367,7 +2367,7 @@ bool IsAegolismSpell(uint16 spell_id) { bool AegolismStackingIsSymbolSpell(uint16 spell_id) { - + /* This is hardcoded to be specific to the type of HP buffs that are removed if a mob has an Aegolism buff. */ @@ -2430,3 +2430,54 @@ bool AegolismStackingIsArmorClassSpell(uint16 spell_id) { return 0; } + +bool IsDisciplineTome(const EQ::ItemData* item) +{ + if (!item->IsClassCommon() || item->ItemType != EQ::item::ItemTypeSpell) { + return false; + } + + //Need a way to determine the difference between a spell and a tome + //so they cant turn in a spell and get it as a discipline + //this is kinda a hack: + + const std::string item_name = item->Name; + + if ( + !Strings::BeginsWith(item_name, "Tome of ") && + !Strings::BeginsWith(item_name, "Skill: ") + ) { + return false; + } + + //we know for sure none of the int casters get disciplines + uint32 class_bit = 0; + class_bit |= 1 << (Class::Wizard - 1); + class_bit |= 1 << (Class::Enchanter - 1); + class_bit |= 1 << (Class::Magician - 1); + class_bit |= 1 << (Class::Necromancer - 1); + if (item->Classes & class_bit) { + return false; + } + + const auto& spell_id = static_cast(item->Scroll.Effect); + if (!IsValidSpell(spell_id)) { + return false; + } + + if (!IsDiscipline(spell_id)) { + return false; + } + + const auto &spell = spells[spell_id]; + if ( + spell.classes[Class::Wizard - 1] != 255 && + spell.classes[Class::Enchanter - 1] != 255 && + spell.classes[Class::Magician - 1] != 255 && + spell.classes[Class::Necromancer - 1] != 255 + ) { + return false; + } + + return true; +} diff --git a/common/spdat.h b/common/spdat.h index d597c79faf..0b29d26887 100644 --- a/common/spdat.h +++ b/common/spdat.h @@ -20,6 +20,7 @@ #include "classes.h" #include "skills.h" +#include "item_data.h" #define SPELL_UNKNOWN 0xFFFF #define POISON_PROC 0xFFFE @@ -1628,5 +1629,6 @@ bool IsCastRestrictedSpell(uint16 spell_id); bool IsAegolismSpell(uint16 spell_id); bool AegolismStackingIsSymbolSpell(uint16 spell_id); bool AegolismStackingIsArmorClassSpell(uint16 spell_id); +bool IsDisciplineTome(const EQ::ItemData* item); #endif diff --git a/common/version.h b/common/version.h index 6e1c31abcf..4afe28eff7 100644 --- a/common/version.h +++ b/common/version.h @@ -42,7 +42,7 @@ * Manifest: https://github.com/EQEmu/Server/blob/master/utils/sql/db_update_manifest.txt */ -#define CURRENT_BINARY_DATABASE_VERSION 9284 +#define CURRENT_BINARY_DATABASE_VERSION 9285 #define CURRENT_BINARY_BOTS_DATABASE_VERSION 9045 #endif diff --git a/zone/attack.cpp b/zone/attack.cpp index 6ffe44099e..c17f23eed3 100644 --- a/zone/attack.cpp +++ b/zone/attack.cpp @@ -2513,6 +2513,17 @@ bool NPC::Death(Mob* killer_mob, int64 damage, uint16 spell, EQ::skills::SkillTy return false; } + if (IsMultiQuestEnabled()) { + for (auto &i: m_hand_in.items) { + if (i.is_multiquest_item && i.item->GetItem()->NoDrop != 0) { + auto lde = LootdropEntriesRepository::NewNpcEntity(); + lde.equip_item = 0; + lde.item_charges = i.item->GetCharges(); + AddLootDrop(i.item->GetItem(), lde, true); + } + } + } + if (killer_mob && killer_mob->IsOfClientBot() && IsValidSpell(spell) && damage > 0) { char val1[20] = { 0 }; diff --git a/zone/cli/npc_handins.cpp b/zone/cli/npc_handins.cpp new file mode 100644 index 0000000000..d4c10b84c8 --- /dev/null +++ b/zone/cli/npc_handins.cpp @@ -0,0 +1,445 @@ +#include "../../common/http/httplib.h" +#include "../../common/eqemu_logsys.h" +#include "../../common/platform.h" +#include "../zone.h" +#include "../client.h" +#include "../../common/net/eqstream.h" + +extern Zone *zone; + +void ZoneCLI::NpcHandins(int argc, char **argv, argh::parser &cmd, std::string &description) +{ + if (cmd[{"-h", "--help"}]) { + return; + } + + RegisterExecutablePlatform(EQEmuExePlatform::ExePlatformZoneSidecar); + + LogInfo("----------------------------------------"); + LogInfo("Booting test zone for NPC handins"); + LogInfo("----------------------------------------"); + + Zone::Bootup(ZoneID("qrg"), 0, false); + zone->StopShutdownTimer(); + + entity_list.Process(); + entity_list.MobProcess(); + + LogInfo("----------------------------------------"); + LogInfo("Done booting test zone"); + LogInfo("----------------------------------------"); + + Client *c = new Client(); + auto npc_type = content_db.LoadNPCTypesData(754008); + if (npc_type) { + auto npc = new NPC( + npc_type, + nullptr, + glm::vec4(0, 0, 0, 0), + GravityBehavior::Water + ); + + entity_list.AddNPC(npc); + + LogInfo("Spawned NPC [{}]", npc->GetCleanName()); + LogInfo("Spawned client [{}]", c->GetCleanName()); + + struct HandinEntry { + std::string item_id = "0"; + uint16 count = 0; + const EQ::ItemInstance *item = nullptr; + bool is_multiquest_item = false; // state + }; + + struct HandinMoney { + uint32 platinum = 0; + uint32 gold = 0; + uint32 silver = 0; + uint32 copper = 0; + }; + + struct Handin { + std::vector items = {}; // items can be removed from this set as successful handins are made + HandinMoney money = {}; // money can be removed from this set as successful handins are made + }; + + struct TestCase { + std::string description = ""; + Handin hand_in; + Handin required; + Handin returned; + bool handin_check_result; + }; + + std::vector test_cases = { + TestCase{ + .description = "Test basic cloth-cap hand-in", + .hand_in = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + }, + .required = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + }, + .returned = {}, + .handin_check_result = true, + }, + TestCase{ + .description = "Test basic cloth-cap hand-in failure", + .hand_in = { + .items = { + HandinEntry{.item_id = "9997", .count = 1}, + }, + }, + .required = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + }, + .returned = { + .items = { + HandinEntry{.item_id = "9997", .count = 1}, + }, + }, + .handin_check_result = false, + }, + TestCase{ + .description = "Test basic cloth-cap hand-in failure from handing in too many", + .hand_in = { + .items = { + HandinEntry{.item_id = "9997", .count = 1}, + HandinEntry{.item_id = "9997", .count = 1}, + }, + }, + .required = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + }, + .returned = { + .items = { + HandinEntry{.item_id = "9997", .count = 1}, + HandinEntry{.item_id = "9997", .count = 1}, + }, + }, + .handin_check_result = false, + }, + TestCase{ + .description = "Test handing in money", + .hand_in = { + .items = {}, + .money = {.platinum = 1}, + }, + .required = { + .items = {}, + .money = {.platinum = 1}, + }, + .returned = {}, + .handin_check_result = true, + }, + TestCase{ + .description = "Test handing in money, but not enough", + .hand_in = { + .items = {}, + .money = {.platinum = 1}, + }, + .required = { + .items = {}, + .money = {.platinum = 100}, + }, + .returned = {}, + .handin_check_result = false, + }, + TestCase{ + .description = "Test handing in money, but not enough of any type", + .hand_in = { + .items = {}, + .money = {.platinum = 1, .gold = 1, .silver = 1, .copper = 1}, + }, + .required = { + .items = {}, + .money = {.platinum = 100, .gold = 100, .silver = 100, .copper = 100}, + }, + .returned = {}, + .handin_check_result = false, + }, + TestCase{ + .description = "Test handing in money of all types", + .hand_in = { + .items = {}, + .money = {.platinum = 1, .gold = 1, .silver = 1, .copper = 1}, + }, + .required = { + .items = {}, + .money = {.platinum = 1, .gold = 1, .silver = 1, .copper = 1}, + }, + .returned = {}, + .handin_check_result = true, + }, + TestCase{ + .description = "Test handing in platinum with items with success", + .hand_in = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + .money = {.platinum = 1}, + }, + .required = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + .money = {.platinum = 1}, + }, + .returned = {}, + .handin_check_result = true, + }, + TestCase{ + .description = "Test handing in platinum with items with failure", + .hand_in = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + .money = {.platinum = 1}, + }, + .required = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + .money = {.platinum = 100}, + }, + .returned = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + }, + .handin_check_result = false, + }, + TestCase{ + .description = "Test returning money and items", + .hand_in = { + .items = { + HandinEntry{.item_id = "1007", .count = 1}, + }, + .money = { + .platinum = 1, + .gold = 666, + .silver = 234, + .copper = 444, + }, + }, + .required = { + .items = { + HandinEntry{.item_id = "1001", .count = 1}, + }, + .money = {.platinum = 100}, + }, + .returned = { + .items = { + HandinEntry{.item_id = "1007", .count = 1}, + }, + .money = { + .platinum = 1, + .gold = 666, + .silver = 234, + .copper = 444, + }, + }, + .handin_check_result = false, + }, + TestCase{ + .description = "Test returning money", + .hand_in = { + .items = {}, + .money = { + .platinum = 1, + .gold = 666, + .silver = 234, + .copper = 444, + }, + }, + .required = { + .items = {}, + .money = {.platinum = 100}, + }, + .returned = { + .items = { + }, + .money = { + .platinum = 1, + .gold = 666, + .silver = 234, + .copper = 444, + }, + }, + .handin_check_result = false, + }, + TestCase{ + .description = "Test handing in many items of the same required item", + .hand_in = { + .items = { + HandinEntry{.item_id = "1007", .count = 1}, + HandinEntry{.item_id = "1007", .count = 1}, + HandinEntry{.item_id = "1007", .count = 1}, + HandinEntry{.item_id = "1007", .count = 1}, + }, + .money = {}, + }, + .required = { + .items = { + HandinEntry{.item_id = "1007", .count = 1}, + }, + .money = {}, + }, + .returned = { + .items = { + HandinEntry{.item_id = "1007", .count = 1}, + HandinEntry{.item_id = "1007", .count = 1}, + HandinEntry{.item_id = "1007", .count = 1}, + HandinEntry{.item_id = "1007", .count = 1}, + }, + .money = { + .platinum = 1, + .gold = 666, + .silver = 234, + .copper = 444, + }, + }, + .handin_check_result = false, + }, + TestCase{ + .description = "Test handing in item of a stack", + .hand_in = { + .items = { + HandinEntry{.item_id = "13005", .count = 20}, + }, + .money = {}, + }, + .required = { + .items = { + HandinEntry{.item_id = "13005", .count = 20}, + }, + .money = {}, + }, + .returned = { + .items = {}, + .money = {}, + }, + .handin_check_result = true, + }, + TestCase{ + .description = "Test handing in item of a stack but not enough", + .hand_in = { + .items = { + HandinEntry{.item_id = "13005", .count = 10}, + }, + .money = {}, + }, + .required = { + .items = { + HandinEntry{.item_id = "13005", .count = 20}, + }, + .money = {}, + }, + .returned = { + .items = { + HandinEntry{.item_id = "13005", .count = 10}, + }, + .money = {}, + }, + .handin_check_result = false, + }, + }; + + std::map hand_ins; + std::map required; + std::vector items; + + // turn this on to see debugging output + LogSys.log_settings[Logs::NpcHandin].log_to_console = 0; + + for (auto &test_case: test_cases) { + hand_ins.clear(); + required.clear(); + items.clear(); + + for (auto &hand_in: test_case.hand_in.items) { + hand_ins[hand_in.item_id] = hand_in.count; + auto item_id = Strings::ToInt(hand_in.item_id); + EQ::ItemInstance *inst = database.CreateItem(item_id); + inst->SetCharges(hand_in.count); + items.push_back(inst); + } + + // money + if (test_case.hand_in.money.platinum > 0) { + hand_ins["platinum"] = test_case.hand_in.money.platinum; + } + if (test_case.hand_in.money.gold > 0) { + hand_ins["gold"] = test_case.hand_in.money.gold; + } + if (test_case.hand_in.money.silver > 0) { + hand_ins["silver"] = test_case.hand_in.money.silver; + } + if (test_case.hand_in.money.copper > 0) { + hand_ins["copper"] = test_case.hand_in.money.copper; + } + + for (auto &req: test_case.required.items) { + required[req.item_id] = req.count; + } + + // money + if (test_case.required.money.platinum > 0) { + required["platinum"] = test_case.required.money.platinum; + } + if (test_case.required.money.gold > 0) { + required["gold"] = test_case.required.money.gold; + } + if (test_case.required.money.silver > 0) { + required["silver"] = test_case.required.money.silver; + } + if (test_case.required.money.copper > 0) { + required["copper"] = test_case.required.money.copper; + } + + auto result = npc->CheckHandin(c, hand_ins, required, items); + if (result != test_case.handin_check_result) { + LogError("FAIL [{}]", test_case.description); + // print out the hand-ins + LogError("Hand-ins >"); + for (auto &hand_in: hand_ins) { + LogError(" > Item [{}] count [{}]", hand_in.first, hand_in.second); + } + LogError("Required >"); + for (auto &req: required) { + LogError(" > Item [{}] count [{}]", req.first, req.second); + } + LogError("Expected [{}] got [{}]", test_case.handin_check_result, result); + } + else { + LogInfo("PASS [{}]", test_case.description); + } + + auto returned = npc->ReturnHandinItems(c); + + // assert that returned items are expected + for (auto &item: test_case.returned.items) { + auto found = false; + for (auto &ret: returned.items) { + if (ret.item_id == item.item_id) { + found = true; + break; + } + } + if (!found) { + LogError("Returned item [{}] not expected", item.item_id); + } + } + + npc->ResetHandin(); + } + } +} diff --git a/zone/client.cpp b/zone/client.cpp index d0a8d97b0b..7cf0963b33 100644 --- a/zone/client.cpp +++ b/zone/client.cpp @@ -87,6 +87,314 @@ extern PetitionList petition_list; void UpdateWindowTitle(char* iNewTitle); +// client constructor purely for testing / mocking +Client::Client() : Mob( + "No name", // in_name + "", // in_lastname + 0, // in_cur_hp + 0, // in_max_hp + Gender::Male, // in_gender + Race::Doug, // in_race + Class::None, // in_class + BodyType::Humanoid, // in_bodytype + Deity::Unknown, // in_deity + 0, // in_level + 0, // in_npctype_id + 0.0f, // in_size + 0.7f, // in_runspeed + glm::vec4(), // position + 0, // in_light + 0xFF, // in_texture + 0xFF, // in_helmtexture + 0, // in_ac + 0, // in_atk + 0, // in_str + 0, // in_sta + 0, // in_dex + 0, // in_agi + 0, // in_int + 0, // in_wis + 0, // in_cha + 0, // in_haircolor + 0, // in_beardcolor + 0, // in_eyecolor1 + 0, // in_eyecolor2 + 0, // in_hairstyle + 0, // in_luclinface + 0, // in_beard + 0, // in_drakkin_heritage + 0, // in_drakkin_tattoo + 0, // in_drakkin_details + EQ::TintProfile(), // in_armor_tint + 0xff, // in_aa_title + 0, // in_see_invis + 0, // in_see_invis_undead + 0, // in_see_hide + 0, // in_see_improved_hide + 0, // in_hp_regen + 0, // in_mana_regen + 0, // in_qglobal + 0, // in_maxlevel + 0, // in_scalerate + 0, // in_armtexture + 0, // in_bracertexture + 0, // in_handtexture + 0, // in_legtexture + 0, // in_feettexture + 0, // in_usemodel + false, // in_always_aggros_foes + 0, // in_heroic_strikethrough + false // in_keeps_sold_items +), + hpupdate_timer(2000), + camp_timer(29000), + process_timer(100), + consume_food_timer(CONSUMPTION_TIMER), + zoneinpacket_timer(1000), + linkdead_timer(RuleI(Zone, ClientLinkdeadMS)), + dead_timer(2000), + global_channel_timer(1000), + fishing_timer(8000), + endupkeep_timer(1000), + autosave_timer(RuleI(Character, AutosaveIntervalS) * 1000), + m_client_npc_aggro_scan_timer(RuleI(Aggro, ClientAggroCheckIdleInterval)), + m_client_zone_wide_full_position_update_timer(5 * 60 * 1000), + tribute_timer(Tribute_duration), + proximity_timer(ClientProximity_interval), + TaskPeriodic_Timer(RuleI(TaskSystem, PeriodicCheckTimer) * 1000), + charm_update_timer(6000), + rest_timer(1), + pick_lock_timer(1000), + charm_class_attacks_timer(3000), + charm_cast_timer(3500), + qglobal_purge_timer(30000), + TrackingTimer(2000), + RespawnFromHoverTimer(0), + merc_timer(RuleI(Mercs, UpkeepIntervalMS)), + ItemQuestTimer(500), + anon_toggle_timer(250), + afk_toggle_timer(250), + helm_toggle_timer(250), + aggro_meter_timer(AGGRO_METER_UPDATE_MS), + m_Proximity(FLT_MAX, FLT_MAX, FLT_MAX), //arbitrary large number + m_ZoneSummonLocation(-2.0f, -2.0f, -2.0f, -2.0f), + m_AutoAttackPosition(0.0f, 0.0f, 0.0f, 0.0f), + m_AutoAttackTargetLocation(0.0f, 0.0f, 0.0f), + last_region_type(RegionTypeUnsupported), + m_dirtyautohaters(false), + m_position_update_timer(10000), + consent_throttle_timer(2000), + tmSitting(0), + parcel_timer(RuleI(Parcel, ParcelDeliveryDelay)), + lazy_load_bank_check_timer(1000), + bandolier_throttle_timer(0) +{ + eqs = nullptr; + for (auto client_filter = FilterNone; client_filter < _FilterCount; client_filter = eqFilterType(client_filter + 1)) { + SetFilter(client_filter, FilterShow); + } + + cheat_manager.SetClient(this); + mMovementManager->AddClient(this); + character_id = 0; + conn_state = NoPacketsReceived; + client_data_loaded = false; + berserk = false; + dead = false; + client_state = CLIENT_CONNECTING; + SetTrader(false); + Haste = 0; + SetCustomerID(0); + SetTraderID(0); + TrackingID = 0; + WID = 0; + account_id = 0; + admin = AccountStatus::Player; + lsaccountid = 0; + guild_id = GUILD_NONE; + guildrank = 0; + guild_tribute_opt_in = 0; + SetGuildListDirty(false); + GuildBanker = false; + memset(lskey, 0, sizeof(lskey)); + strcpy(account_name, ""); + tellsoff = false; + last_reported_mana = 0; + last_reported_endurance = 0; + last_reported_endurance_percent = 0; + last_reported_mana_percent = 0; + gm_hide_me = false; + AFK = false; + LFG = false; + LFGFromLevel = 0; + LFGToLevel = 0; + LFGMatchFilter = false; + LFGComments[0] = '\0'; + LFP = false; + gmspeed = 0; + gminvul = false; + playeraction = 0; + SetTarget(0); + auto_attack = false; + auto_fire = false; + runmode = false; + linkdead_timer.Disable(); + zonesummon_id = 0; + zonesummon_ignorerestrictions = 0; + bZoning = false; + m_lock_save_position = false; + zone_mode = ZoneUnsolicited; + casting_spell_id = 0; + npcflag = false; + npclevel = 0; + fishing_timer.Disable(); + dead_timer.Disable(); + camp_timer.Disable(); + autosave_timer.Disable(); + GetMercTimer()->Disable(); + instalog = false; + m_pp.autosplit = false; + // initialise haste variable + m_tradeskill_object = nullptr; + delaytimer = false; + PendingRezzXP = -1; + PendingRezzDBID = 0; + PendingRezzSpellID = 0; + numclients++; + // emuerror; + UpdateWindowTitle(nullptr); + horseId = 0; + tgb = false; + tribute_master_id = 0xFFFFFFFF; + tribute_timer.Disable(); + task_state = nullptr; + TotalSecondsPlayed = 0; + keyring.clear(); + bind_sight_target = nullptr; + p_raid_instance = nullptr; + mercid = 0; + mercSlot = 0; + InitializeMercInfo(); + SetMerc(0); + if (RuleI(World, PVPMinLevel) > 0 && level >= RuleI(World, PVPMinLevel) && m_pp.pvp == 0) SetPVP(true, false); + dynamiczone_removal_timer.Disable(); + + //for good measure: + memset(&m_pp, 0, sizeof(m_pp)); + memset(&m_epp, 0, sizeof(m_epp)); + PendingTranslocate = false; + PendingSacrifice = false; + sacrifice_caster_id = 0; + controlling_boat_id = 0; + controlled_mob_id = 0; + qGlobals = nullptr; + + if (!RuleB(Character, PerCharacterQglobalMaxLevel) && !RuleB(Character, PerCharacterBucketMaxLevel)) { + SetClientMaxLevel(0); + } else if (RuleB(Character, PerCharacterQglobalMaxLevel)) { + SetClientMaxLevel(GetCharMaxLevelFromQGlobal()); + } else if (RuleB(Character, PerCharacterBucketMaxLevel)) { + SetClientMaxLevel(GetCharMaxLevelFromBucket()); + } + + KarmaUpdateTimer = new Timer(RuleI(Chat, KarmaUpdateIntervalMS)); + GlobalChatLimiterTimer = new Timer(RuleI(Chat, IntervalDurationMS)); + AttemptedMessages = 0; + TotalKarma = 0; + m_ClientVersion = EQ::versions::ClientVersion::Unknown; + m_ClientVersionBit = 0; + AggroCount = 0; + ooc_regen = false; + AreaHPRegen = 1.0f; + AreaManaRegen = 1.0f; + AreaEndRegen = 1.0f; + XPRate = 100; + current_endurance = 0; + + CanUseReport = true; + aa_los_them_mob = nullptr; + los_status = false; + los_status_facing = false; + HideCorpseMode = HideCorpseNone; + PendingGuildInvitation = false; + + InitializeBuffSlots(); + + adventure_request_timer = nullptr; + adventure_create_timer = nullptr; + adventure_leave_timer = nullptr; + adventure_door_timer = nullptr; + adv_requested_data = nullptr; + adventure_stats_timer = nullptr; + adventure_leaderboard_timer = nullptr; + adv_data = nullptr; + adv_requested_theme = LDoNThemes::Unused; + adv_requested_id = 0; + adv_requested_member_count = 0; + + for(int i = 0; i < XTARGET_HARDCAP; ++i) + { + XTargets[i].Type = Auto; + XTargets[i].ID = 0; + XTargets[i].Name[0] = 0; + XTargets[i].dirty = false; + } + MaxXTargets = 5; + XTargetAutoAddHaters = true; + m_autohatermgr.SetOwner(this, nullptr, nullptr); + m_activeautohatermgr = &m_autohatermgr; + + initial_respawn_selection = 0; + alternate_currency_loaded = false; + + interrogateinv_flag = false; + + trapid = 0; + + for (int i = 0; i < InnateSkillMax; ++i) + m_pp.InnateSkills[i] = InnateDisabled; + + temp_pvp = false; + + moving = false; + + environment_damage_modifier = 0; + invulnerable_environment_damage = false; + + // rate limiter + m_list_task_timers_rate_limit.Start(1000); + + // gm + SetDisplayMobInfoWindow(true); + SetDevToolsEnabled(true); + + bot_owner_options[booDeathMarquee] = false; + bot_owner_options[booStatsUpdate] = false; + bot_owner_options[booSpawnMessageSay] = false; + bot_owner_options[booSpawnMessageTell] = true; + bot_owner_options[booSpawnMessageClassSpecific] = true; + bot_owner_options[booAutoDefend] = RuleB(Bots, AllowOwnerOptionAutoDefend); + bot_owner_options[booBuffCounter] = false; + bot_owner_options[booMonkWuMessage] = false; + + m_parcel_platinum = 0; + m_parcel_gold = 0; + m_parcel_silver = 0; + m_parcel_copper = 0; + m_parcel_count = 0; + m_parcel_enabled = true; + m_parcel_merchant_engaged = false; + m_parcels.clear(); + + m_buyer_id = 0; + + SetBotPulling(false); + SetBotPrecombat(false); + + AI_Init(); + +} + Client::Client(EQStreamInterface *ieqs) : Mob( "No name", // in_name "", // in_lastname @@ -499,9 +807,11 @@ Client::~Client() { zone->RemoveAuth(GetName(), lskey); //let the stream factory know were done with this stream - eqs->Close(); - eqs->ReleaseFromUse(); - safe_delete(eqs); + if (eqs) { + eqs->Close(); + eqs->ReleaseFromUse(); + safe_delete(eqs); + } UninitializeBuffSlots(); } @@ -12320,248 +12630,6 @@ void Client::PlayerTradeEventLog(Trade *t, Trade *t2) RecordPlayerEventLogWithClient(trader2, PlayerEvent::TRADE, e); } -void Client::NPCHandinEventLog(Trade* t, NPC* n) -{ - Client* c = t->GetOwner()->CastToClient(); - - std::vector hi = {}; - std::vector ri = {}; - PlayerEvent::HandinMoney hm{}; - PlayerEvent::HandinMoney rm{}; - - if ( - c->EntityVariableExists("HANDIN_ITEMS") && - c->EntityVariableExists("HANDIN_MONEY") && - c->EntityVariableExists("RETURN_ITEMS") && - c->EntityVariableExists("RETURN_MONEY") - ) { - const std::string& handin_items = c->GetEntityVariable("HANDIN_ITEMS"); - const std::string& return_items = c->GetEntityVariable("RETURN_ITEMS"); - const std::string& handin_money = c->GetEntityVariable("HANDIN_MONEY"); - const std::string& return_money = c->GetEntityVariable("RETURN_MONEY"); - - // Handin Items - if (!handin_items.empty()) { - if (Strings::Contains(handin_items, ",")) { - const auto handin_data = Strings::Split(handin_items, ","); - for (const auto& h : handin_data) { - const auto item_data = Strings::Split(h, "|"); - if ( - item_data.size() == 3 && - Strings::IsNumber(item_data[0]) && - Strings::IsNumber(item_data[1]) && - Strings::IsNumber(item_data[2]) - ) { - const uint32 item_id = Strings::ToUnsignedInt(item_data[0]); - if (item_id != 0) { - const auto* item = database.GetItem(item_id); - - if (item) { - hi.emplace_back( - PlayerEvent::HandinEntry{ - .item_id = item_id, - .item_name = item->Name, - .charges = static_cast(Strings::ToUnsignedInt(item_data[1])), - .attuned = Strings::ToInt(item_data[2]) ? true : false - } - ); - } - } - } - } - } else if (Strings::Contains(handin_items, "|")) { - const auto item_data = Strings::Split(handin_items, "|"); - if ( - item_data.size() == 3 && - Strings::IsNumber(item_data[0]) && - Strings::IsNumber(item_data[1]) && - Strings::IsNumber(item_data[2]) - ) { - const uint32 item_id = Strings::ToUnsignedInt(item_data[0]); - const auto* item = database.GetItem(item_id); - - if (item) { - hi.emplace_back( - PlayerEvent::HandinEntry{ - .item_id = item_id, - .item_name = item->Name, - .charges = static_cast(Strings::ToUnsignedInt(item_data[1])), - .attuned = Strings::ToInt(item_data[2]) ? true : false - } - ); - } - } - } - } - - // Handin Money - if (!handin_money.empty()) { - const auto hms = Strings::Split(handin_money, "|"); - - hm.copper = Strings::ToUnsignedInt(hms[0]); - hm.silver = Strings::ToUnsignedInt(hms[1]); - hm.gold = Strings::ToUnsignedInt(hms[2]); - hm.platinum = Strings::ToUnsignedInt(hms[3]); - } - - // Return Items - if (!return_items.empty()) { - if (Strings::Contains(return_items, ",")) { - const auto return_data = Strings::Split(return_items, ","); - for (const auto& r : return_data) { - const auto item_data = Strings::Split(r, "|"); - if ( - item_data.size() == 3 && - Strings::IsNumber(item_data[0]) && - Strings::IsNumber(item_data[1]) && - Strings::IsNumber(item_data[2]) - ) { - const uint32 item_id = Strings::ToUnsignedInt(item_data[0]); - const auto* item = database.GetItem(item_id); - - if (item) { - ri.emplace_back( - PlayerEvent::HandinEntry{ - .item_id = item_id, - .item_name = item->Name, - .charges = static_cast(Strings::ToUnsignedInt(item_data[1])), - .attuned = Strings::ToInt(item_data[2]) ? true : false - } - ); - } - } - } - } else if (Strings::Contains(return_items, "|")) { - const auto item_data = Strings::Split(return_items, "|"); - if ( - item_data.size() == 3 && - Strings::IsNumber(item_data[0]) && - Strings::IsNumber(item_data[1]) && - Strings::IsNumber(item_data[2]) - ) { - const uint32 item_id = Strings::ToUnsignedInt(item_data[0]); - const auto* item = database.GetItem(item_id); - - if (item) { - ri.emplace_back( - PlayerEvent::HandinEntry{ - .item_id = item_id, - .item_name = item->Name, - .charges = static_cast(Strings::ToUnsignedInt(item_data[1])), - .attuned = Strings::ToInt(item_data[2]) ? true : false - } - ); - } - } - } - } - - // Return Money - if (!return_money.empty()) { - const auto rms = Strings::Split(return_money, "|"); - rm.copper = static_cast(Strings::ToUnsignedInt(rms[0])); - rm.silver = static_cast(Strings::ToUnsignedInt(rms[1])); - rm.gold = static_cast(Strings::ToUnsignedInt(rms[2])); - rm.platinum = static_cast(Strings::ToUnsignedInt(rms[3])); - } - - c->DeleteEntityVariable("HANDIN_ITEMS"); - c->DeleteEntityVariable("HANDIN_MONEY"); - c->DeleteEntityVariable("RETURN_ITEMS"); - c->DeleteEntityVariable("RETURN_MONEY"); - - const bool handed_in_money = hm.platinum > 0 || hm.gold > 0 || hm.silver > 0 || hm.copper > 0; - - const bool event_has_data_to_record = ( - !hi.empty() || handed_in_money - ); - - if (player_event_logs.IsEventEnabled(PlayerEvent::NPC_HANDIN) && event_has_data_to_record) { - auto e = PlayerEvent::HandinEvent{ - .npc_id = n->GetNPCTypeID(), - .npc_name = n->GetCleanName(), - .handin_items = hi, - .handin_money = hm, - .return_items = ri, - .return_money = rm, - .is_quest_handin = true - }; - - RecordPlayerEventLogWithClient(c, PlayerEvent::NPC_HANDIN, e); - } - - return; - } - - uint8 item_count = 0; - - hm.platinum = t->pp; - hm.gold = t->gp; - hm.silver = t->sp; - hm.copper = t->cp; - - for (uint16 i = EQ::invslot::TRADE_BEGIN; i <= EQ::invslot::TRADE_NPC_END; i++) { - if (c->GetInv().GetItem(i)) { - item_count++; - } - } - - hi.reserve(item_count); - - if (item_count > 0) { - for (uint16 i = EQ::invslot::TRADE_BEGIN; i <= EQ::invslot::TRADE_NPC_END; i++) { - const EQ::ItemInstance* inst = c->GetInv().GetItem(i); - if (inst) { - hi.emplace_back( - PlayerEvent::HandinEntry{ - .item_id = inst->GetItem()->ID, - .item_name = inst->GetItem()->Name, - .charges = static_cast(inst->GetCharges()), - .attuned = inst->IsAttuned() - } - ); - - if (inst->IsClassBag()) { - for (uint8 j = EQ::invbag::SLOT_BEGIN; j <= EQ::invbag::SLOT_END; j++) { - inst = c->GetInv().GetItem(i, j); - if (inst) { - hi.emplace_back( - PlayerEvent::HandinEntry{ - .item_id = inst->GetItem()->ID, - .item_name = inst->GetItem()->Name, - .charges = static_cast(inst->GetCharges()), - .attuned = inst->IsAttuned() - } - ); - } - } - } - } - } - } - - const bool handed_in_money = hm.platinum > 0 || hm.gold > 0 || hm.silver > 0 || hm.copper > 0; - - ri = hi; - rm = hm; - - const bool event_has_data_to_record = !hi.empty() || handed_in_money; - - if (player_event_logs.IsEventEnabled(PlayerEvent::NPC_HANDIN) && event_has_data_to_record) { - auto e = PlayerEvent::HandinEvent{ - .npc_id = n->GetNPCTypeID(), - .npc_name = n->GetCleanName(), - .handin_items = hi, - .handin_money = hm, - .return_items = ri, - .return_money = rm, - .is_quest_handin = false - }; - - RecordPlayerEventLogWithClient(c, PlayerEvent::NPC_HANDIN, e); - } -} - void Client::ShowSpells(Client* c, ShowSpellType show_spell_type) { std::string spell_string; diff --git a/zone/client.h b/zone/client.h index 5acc388b3e..f4c75eb0fe 100644 --- a/zone/client.h +++ b/zone/client.h @@ -239,6 +239,7 @@ class Client : public Mob #include "client_packet.h" Client(EQStreamInterface * ieqs); + Client(); // mocking ~Client(); void ReconnectUCS(); @@ -1809,6 +1810,7 @@ class Client : public Mob void RecordKilledNPCEvent(NPC *n); uint32 GetEXPForLevel(uint16 check_level); + protected: friend class Mob; void CalcEdibleBonuses(StatBonuses* newbon); @@ -2227,11 +2229,11 @@ class Client : public Mob bool CanTradeFVNoDropItem(); void SendMobPositions(); void PlayerTradeEventLog(Trade *t, Trade *t2); - void NPCHandinEventLog(Trade* t, NPC* n); // full and partial mail key cache std::string m_mail_key_full; std::string m_mail_key; + public: const std::string &GetMailKeyFull() const; const std::string &GetMailKey() const; diff --git a/zone/client_packet.cpp b/zone/client_packet.cpp index c880e310f8..1f2b7c7c77 100644 --- a/zone/client_packet.cpp +++ b/zone/client_packet.cpp @@ -17226,3 +17226,5 @@ void Client::Handle_OP_ShopRetrieveParcel(const EQApplicationPacket *app) auto parcel_in = (ParcelRetrieve_Struct *)app->pBuffer; DoParcelRetrieve(*parcel_in); } + + diff --git a/zone/inventory.cpp b/zone/inventory.cpp index 30f4c31daf..def91cd878 100644 --- a/zone/inventory.cpp +++ b/zone/inventory.cpp @@ -3173,8 +3173,13 @@ uint32 Client::GetEquipmentColor(uint8 material_slot) const // Send an item packet (including all subitems of the item) void Client::SendItemPacket(int16 slot_id, const EQ::ItemInstance* inst, ItemPacketType packet_type) { - if (!inst) + if (!inst) { + return; + } + + if (!eqs) { return; + } if (packet_type != ItemPacketMerchant) { if (slot_id <= EQ::invslot::POSSESSIONS_END && slot_id >= EQ::invslot::POSSESSIONS_BEGIN) { diff --git a/zone/lua_iteminst.cpp b/zone/lua_iteminst.cpp index ad9ed14054..f3b5dfab13 100644 --- a/zone/lua_iteminst.cpp +++ b/zone/lua_iteminst.cpp @@ -305,6 +305,12 @@ std::string Lua_ItemInst::GetName() { return self->GetItem()->Name; } +int Lua_ItemInst::GetSerialNumber() +{ + Lua_Safe_Call_Int(); + return self->GetSerialNumber(); +} + void Lua_ItemInst::ItemSay(const char* text) // @categories Inventory and Items { Lua_Safe_Call_Void(); @@ -379,6 +385,7 @@ luabind::scope lua_register_iteminst() { .def("GetKillsNeeded", (uint32(Lua_ItemInst::*)(int))&Lua_ItemInst::GetKillsNeeded) .def("GetMaxEvolveLvl", (int(Lua_ItemInst::*)(void))&Lua_ItemInst::GetMaxEvolveLvl) .def("GetName", (std::string(Lua_ItemInst::*)(void))&Lua_ItemInst::GetName) + .def("GetSerialNumber", (int(Lua_ItemInst::*)(void))&Lua_ItemInst::GetSerialNumber) .def("GetPrice", (uint32(Lua_ItemInst::*)(void))&Lua_ItemInst::GetPrice) .def("GetTaskDeliveredCount", &Lua_ItemInst::GetTaskDeliveredCount) .def("GetTotalItemCount", (uint8(Lua_ItemInst::*)(void))&Lua_ItemInst::GetTotalItemCount) diff --git a/zone/lua_iteminst.h b/zone/lua_iteminst.h index 2ff99165c2..6fd3a93ac1 100644 --- a/zone/lua_iteminst.h +++ b/zone/lua_iteminst.h @@ -87,6 +87,7 @@ class Lua_ItemInst : public Lua_Ptr int GetTaskDeliveredCount(); int RemoveTaskDeliveredItems(); std::string GetName(); + int GetSerialNumber(); void ItemSay(const char* text); void ItemSay(const char* text, uint8 language_id); luabind::object GetAugmentIDs(lua_State* L); diff --git a/zone/lua_npc.cpp b/zone/lua_npc.cpp index b17804edce..7e3021378e 100644 --- a/zone/lua_npc.cpp +++ b/zone/lua_npc.cpp @@ -7,6 +7,8 @@ #include "npc.h" #include "lua_npc.h" #include "lua_client.h" +#include "lua_item.h" +#include "lua_iteminst.h" struct Lua_NPC_Loot_List { std::vector entries; @@ -837,6 +839,99 @@ void Lua_NPC::DescribeSpecialAbilities(Lua_Client c) self->DescribeSpecialAbilities(c); } +bool Lua_NPC::IsMultiQuestEnabled() +{ + Lua_Safe_Call_Bool(); + return self->IsMultiQuestEnabled(); +} + +void Lua_NPC::MultiQuestEnable() +{ + Lua_Safe_Call_Void(); + self->MultiQuestEnable(); +} + +bool Lua_NPC::LuaCheckHandin( + Lua_Client c, + luabind::adl::object handin_table, + luabind::adl::object required_table, + luabind::adl::object items_table +) +{ + Lua_Safe_Call_Bool(); + + if ( + luabind::type(handin_table) != LUA_TTABLE || + luabind::type(required_table) != LUA_TTABLE || + luabind::type(items_table) != LUA_TTABLE + ) { + return false; + } + + std::map handin_map; + std::map required_map; + std::vector items; + + for (luabind::iterator i(handin_table), end; i != end; i++) { + std::string key; + if (luabind::type(i.key()) == LUA_TSTRING) { + key = luabind::object_cast(i.key()); + } + else if (luabind::type(i.key()) == LUA_TNUMBER) { + key = fmt::format("{}", luabind::object_cast(i.key())); + } + else { + LogError("Handin key type [{}] not supported", luabind::type(i.key())); + } + + if (!key.empty()) { + handin_map[key] = luabind::object_cast(handin_table[i.key()]); + LogNpcHandinDetail("Handin key [{}] value [{}]", key, handin_map[key]); + } + } + + for (luabind::iterator i(required_table), end; i != end; i++) { + std::string key; + if (luabind::type(i.key()) == LUA_TSTRING) { + key = luabind::object_cast(i.key()); + } + else if (luabind::type(i.key()) == LUA_TNUMBER) { + key = fmt::format("{}", luabind::object_cast(i.key())); + } + else { + LogError("Required key type [{}] not supported", luabind::type(i.key())); + } + + if (!key.empty()) { + required_map[key] = luabind::object_cast(required_table[i.key()]); + LogNpcHandinDetail("Required key [{}] value [{}]", key, required_map[key]); + } + } + + for (luabind::iterator i(items_table), end; i != end; i++) { + auto item = luabind::object_cast(items_table[i.key()]); + + if (item && item.GetItem()) { + LogNpcHandinDetail( + "Item instance [{}] ({}) UUID ({}) added to handin list", + item.GetName(), + item.GetID(), + item.GetSerialNumber() + ); + + items.emplace_back(item); + } + } + + return self->CheckHandin(c, handin_map, required_map, items); +} + +void Lua_NPC::ReturnHandinItems(Lua_Client c) +{ + Lua_Safe_Call_Void(); + self->ReturnHandinItems(c); +} + luabind::scope lua_register_npc() { return luabind::class_("NPC") .def(luabind::constructor<>()) @@ -859,6 +954,7 @@ luabind::scope lua_register_npc() { .def("AssignWaypoints", (void(Lua_NPC::*)(int))&Lua_NPC::AssignWaypoints) .def("CalculateNewWaypoint", (void(Lua_NPC::*)(void))&Lua_NPC::CalculateNewWaypoint) .def("ChangeLastName", (void(Lua_NPC::*)(std::string))&Lua_NPC::ChangeLastName) + .def("CheckHandin", (bool(Lua_NPC::*)(Lua_Client,luabind::adl::object,luabind::adl::object,luabind::adl::object))&Lua_NPC::LuaCheckHandin) .def("CheckNPCFactionAlly", (int(Lua_NPC::*)(int))&Lua_NPC::CheckNPCFactionAlly) .def("ClearItemList", (void(Lua_NPC::*)(void))&Lua_NPC::ClearLootItems) .def("ClearLastName", (void(Lua_NPC::*)(void))&Lua_NPC::ClearLastName) @@ -930,6 +1026,7 @@ luabind::scope lua_register_npc() { .def("IsAnimal", (bool(Lua_NPC::*)(void))&Lua_NPC::IsAnimal) .def("IsGuarding", (bool(Lua_NPC::*)(void))&Lua_NPC::IsGuarding) .def("IsLDoNLocked", (bool(Lua_NPC::*)(void))&Lua_NPC::IsLDoNLocked) + .def("IsMultiQuestEnabled", (bool(Lua_NPC::*)(void))&Lua_NPC::IsMultiQuestEnabled) .def("IsLDoNTrapped", (bool(Lua_NPC::*)(void))&Lua_NPC::IsLDoNTrapped) .def("IsLDoNTrapDetected", (bool(Lua_NPC::*)(void))&Lua_NPC::IsLDoNTrapDetected) .def("IsOnHatelist", (bool(Lua_NPC::*)(Lua_Mob))&Lua_NPC::IsOnHatelist) @@ -941,6 +1038,7 @@ luabind::scope lua_register_npc() { .def("MerchantOpenShop", (void(Lua_NPC::*)(void))&Lua_NPC::MerchantOpenShop) .def("ModifyNPCStat", (void(Lua_NPC::*)(std::string,std::string))&Lua_NPC::ModifyNPCStat) .def("MoveTo", (void(Lua_NPC::*)(float,float,float,float,bool))&Lua_NPC::MoveTo) + .def("MultiQuestEnable", &Lua_NPC::MultiQuestEnable) .def("NextGuardPosition", (void(Lua_NPC::*)(void))&Lua_NPC::NextGuardPosition) .def("PauseWandering", (void(Lua_NPC::*)(int))&Lua_NPC::PauseWandering) .def("PickPocket", (void(Lua_NPC::*)(Lua_Client))&Lua_NPC::PickPocket) @@ -953,6 +1051,7 @@ luabind::scope lua_register_npc() { .def("RemoveItem", (void(Lua_NPC::*)(int,int))&Lua_NPC::RemoveItem) .def("RemoveItem", (void(Lua_NPC::*)(int,int,int))&Lua_NPC::RemoveItem) .def("ResumeWandering", (void(Lua_NPC::*)(void))&Lua_NPC::ResumeWandering) + .def("ReturnHandinItems", (void(Lua_NPC::*)(Lua_Client))&Lua_NPC::ReturnHandinItems) .def("SaveGuardSpot", (void(Lua_NPC::*)(void))&Lua_NPC::SaveGuardSpot) .def("SaveGuardSpot", (void(Lua_NPC::*)(bool))&Lua_NPC::SaveGuardSpot) .def("SaveGuardSpot", (void(Lua_NPC::*)(float,float,float,float))&Lua_NPC::SaveGuardSpot) diff --git a/zone/lua_npc.h b/zone/lua_npc.h index ec916593ad..6f74e1ab6b 100644 --- a/zone/lua_npc.h +++ b/zone/lua_npc.h @@ -9,6 +9,7 @@ class Lua_Mob; class Lua_NPC; class Lua_Client; struct Lua_NPC_Loot_List; +class Lua_Inventory; namespace luabind { struct scope; @@ -186,6 +187,15 @@ class Lua_NPC : public Lua_Mob void SetNPCAggro(bool in_npc_aggro); uint32 GetNPCSpellsEffectsID(); void DescribeSpecialAbilities(Lua_Client c); + bool IsMultiQuestEnabled(); + void MultiQuestEnable(); + bool LuaCheckHandin( + Lua_Client c, + luabind::adl::object handin_table, + luabind::adl::object required_table, + luabind::adl::object items_table + ); + void ReturnHandinItems(Lua_Client c); }; #endif diff --git a/zone/lua_parser_events.cpp b/zone/lua_parser_events.cpp index fb6cb87084..f208d922a3 100644 --- a/zone/lua_parser_events.cpp +++ b/zone/lua_parser_events.cpp @@ -56,6 +56,11 @@ void handle_npc_event_trade( uint32 extra_data, std::vector *extra_pointers ) { + Lua_NPC l_npc(reinterpret_cast(npc)); + luabind::adl::object l_npc_o = luabind::adl::object(L, l_npc); + l_npc_o.push(L); + lua_setfield(L, -2, "self"); + Lua_Client l_client(reinterpret_cast(init)); luabind::adl::object l_client_o = luabind::adl::object(L, l_client); l_client_o.push(L); @@ -102,7 +107,11 @@ void handle_npc_event_trade( lua_pushinteger(L, money_value); lua_setfield(L, -2, "copper"); - // set a reference to the client inside of the trade object as well for plugins to process + // set a reference to the NPC inside the trade object as well for plugins to process + l_npc_o.push(L); + lua_setfield(L, -2, "self"); + + // set a reference to the client inside the trade object as well for plugins to process l_client_o.push(L); lua_setfield(L, -2, "other"); diff --git a/zone/main.cpp b/zone/main.cpp index a05af774a4..01fafea663 100644 --- a/zone/main.cpp +++ b/zone/main.cpp @@ -122,6 +122,7 @@ void CatchSignal(int sig_num); extern void MapOpcodes(); +bool CheckForCompatibleQuestPlugins(); int main(int argc, char **argv) { RegisterExecutablePlatform(ExePlatformZone); @@ -296,7 +297,7 @@ int main(int argc, char **argv) } // command handler - if (ZoneCLI::RanConsoleCommand(argc, argv) && !ZoneCLI::RanSidecarCommand(argc, argv)) { + if (ZoneCLI::RanConsoleCommand(argc, argv) && !(ZoneCLI::RanSidecarCommand(argc, argv) || ZoneCLI::RanTestCommand(argc, argv))) { LogSys.EnableConsoleLogging(); ZoneCLI::CommandHandler(argc, argv); } @@ -367,6 +368,11 @@ int main(int argc, char **argv) return 1; } + if (!CheckForCompatibleQuestPlugins()) { + LogError("Incompatible quest plugins detected, please update your plugins to the latest version"); + return 1; + } + // load these here for now until spells and items can be truly repointed to "content_db" database.SetSharedItemsCount(content_db.GetItemsCount()); database.SetSharedSpellsCount(content_db.GetSpellsCount()); @@ -474,7 +480,8 @@ int main(int argc, char **argv) worldserver.SetScheduler(&event_scheduler); // sidecar command handler - if (ZoneCLI::RanConsoleCommand(argc, argv) && ZoneCLI::RanSidecarCommand(argc, argv)) { + if (ZoneCLI::RanConsoleCommand(argc, argv) + && (ZoneCLI::RanSidecarCommand(argc, argv) || ZoneCLI::RanTestCommand(argc, argv))) { ZoneCLI::CommandHandler(argc, argv); } @@ -705,3 +712,43 @@ void UpdateWindowTitle(char *iNewTitle) SetConsoleTitle(tmp); #endif } + +bool CheckForCompatibleQuestPlugins() +{ + const std::vector& directories = { "lua_modules", "plugins" }; + + bool lua_found = false; + bool perl_found = false; + + for (const auto& directory : directories) { + for (const auto& file : fs::directory_iterator(path.GetServerPath() + "/" + directory)) { + if (file.is_regular_file()) { + auto f = file.path().string(); + if (File::Exists(f)) { + auto r = File::GetContents(std::filesystem::path{ f }.string()); + if (Strings::Contains(r.contents, "CheckHandin")) { + if (Strings::EqualFold(directory, "lua_modules")) { + lua_found = true; + } else if (Strings::EqualFold(directory, "plugins")) { + perl_found = true; + } + + if (lua_found && perl_found) { + return true; + } + } + } + } + } + } + + if (!lua_found) { + LogError("Failed to find CheckHandin in lua_modules"); + } + + if (!perl_found) { + LogError("Failed to find CheckHandin in plugins"); + } + + return lua_found && perl_found; +} diff --git a/zone/mob.cpp b/zone/mob.cpp index a1d9b5d380..217affa50b 100644 --- a/zone/mob.cpp +++ b/zone/mob.cpp @@ -8625,3 +8625,27 @@ std::unordered_map &Mob::GetCloseMobList(float distance) { return entity_list.GetCloseMobList(this, distance); } + +bool Mob::IsGuildmaster() const { + switch (GetClass()) { + case Class::WarriorGM: + case Class::ClericGM: + case Class::PaladinGM: + case Class::RangerGM: + case Class::ShadowKnightGM: + case Class::DruidGM: + case Class::MonkGM: + case Class::BardGM: + case Class::RogueGM: + case Class::ShamanGM: + case Class::NecromancerGM: + case Class::WizardGM: + case Class::MagicianGM: + case Class::EnchanterGM: + case Class::BeastlordGM: + case Class::BerserkerGM: + return true; + default: + return false; + } +} diff --git a/zone/mob.h b/zone/mob.h index 8914534cb7..5ac91ffb15 100644 --- a/zone/mob.h +++ b/zone/mob.h @@ -1492,6 +1492,8 @@ class Mob : public Entity { std::unordered_map &GetCloseMobList(float distance = 0.0f); void CheckScanCloseMobsMovingTimer(); + bool IsGuildmaster() const; + protected: void CommonDamage(Mob* other, int64 &damage, const uint16 spell_id, const EQ::skills::SkillType attack_skill, bool &avoidable, const int8 buffslot, const bool iBuffTic, eSpecialAttacks specal = eSpecialAttacks::None); static uint16 GetProcID(uint16 spell_id, uint8 effect_index); diff --git a/zone/npc.cpp b/zone/npc.cpp index 2172a529e6..78bbb4c37a 100644 --- a/zone/npc.cpp +++ b/zone/npc.cpp @@ -49,6 +49,7 @@ #include "bot.h" #include "../common/skill_caps.h" +#include "../common/events/player_event_logs.h" #include #include @@ -226,6 +227,7 @@ NPC::NPC(const NPCType *npc_type_data, Spawn2 *in_respawn, const glm::vec4 &posi ATK = npc_type_data->ATK; heroic_strikethrough = npc_type_data->heroic_strikethrough; keeps_sold_items = npc_type_data->keeps_sold_items; + m_multiquest_enabled = npc_type_data->multiquest_enabled; // used for when switch back to charm default_ac = npc_type_data->AC; @@ -4232,3 +4234,519 @@ void NPC::DoNpcToNpcAggroScan() false ); } + +bool NPC::CanPetTakeItem(const EQ::ItemInstance *inst) +{ + if (!inst) { + return false; + } + + if (!IsPetOwnerClient()) { + return false; + } + + const bool can_take_nodrop = RuleB(Pets, CanTakeNoDrop) || inst->GetItem()->NoDrop != 0; + const bool can_pet_take_item = !inst->GetItem()->IsQuestItem() + && can_take_nodrop + && inst->GetItem()->IsPetUsable() + && !inst->IsAttuned(); + + if (!can_pet_take_item) { + return false; + } + + return true; +} + +bool NPC::IsGuildmasterForClient(Client *c) { + std::map guildmaster_map = { + { Class::Warrior, Class::WarriorGM }, + { Class::Cleric, Class::ClericGM }, + { Class::Paladin, Class::PaladinGM }, + { Class::Ranger, Class::RangerGM }, + { Class::ShadowKnight, Class::ShadowKnightGM }, + { Class::Druid, Class::DruidGM }, + { Class::Monk, Class::MonkGM }, + { Class::Bard, Class::BardGM }, + { Class::Rogue, Class::RogueGM }, + { Class::Shaman, Class::ShamanGM }, + { Class::Necromancer, Class::NecromancerGM }, + { Class::Wizard, Class::WizardGM }, + { Class::Magician, Class::MagicianGM }, + { Class::Enchanter, Class::EnchanterGM }, + { Class::Beastlord, Class::BeastlordGM }, + { Class::Berserker, Class::BerserkerGM }, + }; + + if (guildmaster_map.find(c->GetClass()) != guildmaster_map.end()) { + if (guildmaster_map[c->GetClass()] == GetClass()) { + return true; + } + } + + return false; +} + +bool NPC::CheckHandin( + Client *c, + std::map handin, + std::map required, + std::vector items +) +{ + auto h = Handin{}; + auto r = Handin{}; + + std::string log_handin_prefix = fmt::format("[{}] -> [{}]", c->GetCleanName(), GetCleanName()); + + // if the npc is a multi-quest npc, we want to re-use our previously set hand-in bucket + if (!m_handin_started && IsMultiQuestEnabled()) { + h = m_hand_in; + } + + std::vector&, Handin&>> datasets = {}; + + // if we've already started the hand-in process, we don't want to re-process the hand-in data + // we continue to use the originally set hand-in bucket and decrement from it with each successive hand-in + if (m_handin_started) { + h = m_hand_in; + } else { + datasets.emplace_back(handin, h); + } + datasets.emplace_back(required, r); + + const std::string set_hand_in = "Hand-in"; + const std::string set_required = "Required"; + for (const auto &[data_map, current_handin]: datasets) { + std::string current_dataset = ¤t_handin == &h ? set_hand_in : set_required; + for (const auto &[key, value]: data_map) { + LogNpcHandinDetail("Processing [{}] key [{}] value [{}]", current_dataset, key, value); + + // Handle items + if (Strings::IsNumber(key)) { + if (const auto *exists = database.GetItem(Strings::ToUnsignedInt(key)); + exists && current_dataset == set_required) { + current_handin.items.emplace_back(HandinEntry{.item_id = key, .count = value}); + } + continue; + } + + // Handle money and any other key-value pairs + if (key == "platinum") { current_handin.money.platinum = value; } + else if (key == "gold") { current_handin.money.gold = value; } + else if (key == "silver") { current_handin.money.silver = value; } + else if (key == "copper") { current_handin.money.copper = value; } + } + } + + // pull hand-in items from the item instances + if (!m_handin_started) { + for (const auto &i: items) { + if (!i) { + continue; + } + + h.items.emplace_back( + HandinEntry{ + .item_id = std::to_string(i->GetItem()->ID), + .count = std::max(static_cast(i->GetCharges()), static_cast(1)), + .item = i->Clone(), + .is_multiquest_item = false + } + ); + } + } + + // compare hand-in to required, the item_id can be in any slot + bool requirement_met = true; + + // money + bool money_met = h.money.platinum == r.money.platinum + && h.money.gold == r.money.gold + && h.money.silver == r.money.silver + && h.money.copper == r.money.copper; + + // items + bool items_met = true; + if (h.items.size() == r.items.size() && !h.items.empty() && !r.items.empty()) { + for (const auto &r_item: r.items) { + bool found = false; + for (auto &h_item: h.items) { + if (h_item.item_id == r_item.item_id && h_item.count == r_item.count) { + found = true; + LogNpcHandinDetail( + "{} >>>> Found required item [{}] ({}) count [{}]", + log_handin_prefix, + h_item.item->GetItem()->Name, + h_item.item_id, + h_item.count + ); + break; + } + } + + if (!found) { + items_met = false; + break; + } + } + } + else if (h.items.empty() && r.items.empty()) { + items_met = true; + } + else { + items_met = false; + } + + requirement_met = money_met && items_met; + + // multi-quest + if (IsMultiQuestEnabled()) { + for (auto &h_item: h.items) { + for (const auto &r_item: r.items) { + if (h_item.item_id == r_item.item_id && h_item.count == r_item.count) { + h_item.is_multiquest_item = true; + } + } + } + } + + for (auto &h_item: h.items) { + LogNpcHandinDetail( + "{} Hand-in item [{}] ({}) count [{}] is_multiquest_item [{}]", + log_handin_prefix, + h_item.item->GetItem()->Name, + h_item.item_id, + h_item.count, + h_item.is_multiquest_item + ); + } + + // in-case we trigger CheckHand-in multiple times, only set these once + if (!m_handin_started) { + m_handin_started = true; + m_hand_in = h; + // save original items for logging + m_hand_in.original_items = m_hand_in.items; + m_hand_in.original_money = m_hand_in.money; + } + + // check if npc is guildmaster + if (IsGuildmaster()) { + for (const auto &h_item: m_hand_in.items) { + if (!h_item.item) { + continue; + } + + if (!IsDisciplineTome(h_item.item->GetItem())) { + continue; + } + + if (IsGuildmasterForClient(c)) { + c->TrainDiscipline(h_item.item->GetID()); + m_hand_in.items.erase( + std::remove_if( + m_hand_in.items.begin(), + m_hand_in.items.end(), + [&](const HandinEntry &i) { + bool removed = i.item_id == h_item.item_id; + if (removed) { + LogNpcHandin( + "{} Hand-in success, removing discipline tome [{}] from hand-in bucket", + log_handin_prefix, + i.item_id + ); + } + return removed; + } + ), + m_hand_in.items.end() + ); + } else { + Say("You are not a member of my guild. I will not train you!"); + requirement_met = false; + break; + } + } + } + + // print current hand-in bucket + LogNpcHandin( + "{} > Before processing hand-in | requirement_met [{}] item_count [{}] platinum [{}] gold [{}] silver [{}] copper [{}]", + log_handin_prefix, + requirement_met, + h.items.size(), + h.money.platinum, + h.money.gold, + h.money.silver, + h.money.copper + ); + + LogNpcHandin( + "{} >> Handed Items | Item(s) ({}) platinum [{}] gold [{}] silver [{}] copper [{}]", + log_handin_prefix, + h.items.size(), + h.money.platinum, + h.money.gold, + h.money.silver, + h.money.copper + ); + + int item_count = 1; + for (const auto &i: h.items) { + LogNpcHandin( + "{} >>> item{} [{}] ({}) count [{}]", + log_handin_prefix, + item_count, + i.item->GetItem()->Name, + i.item_id, + i.count + ); + item_count++; + } + + LogNpcHandin( + "{} >> Required Items | Item(s) ({}) platinum [{}] gold [{}] silver [{}] copper [{}]", + log_handin_prefix, + r.items.size(), + r.money.platinum, + r.money.gold, + r.money.silver, + r.money.copper + ); + + item_count = 1; + for (const auto &i: r.items) { + auto item = database.GetItem(Strings::ToUnsignedInt(i.item_id)); + + LogNpcHandin( + "{} >>> item{} [{}] ({}) count [{}]", + log_handin_prefix, + item_count, + item ? item->Name : "Unknown", + i.item_id, + i.count + ); + + item_count++; + } + + if (requirement_met) { + std::vector log_entries = {}; + for (const auto &h_item : h.items) { + m_hand_in.items.erase( + std::remove_if( + m_hand_in.items.begin(), + m_hand_in.items.end(), + [&](const HandinEntry &i) { + bool removed = i.item_id == h_item.item_id + && i.count == h_item.count + && i.item->GetSerialNumber() == h_item.item->GetSerialNumber(); + if (removed) { + log_entries.emplace_back( + fmt::format( + "{} >>> Hand-in success | Removing from hand-in bucket | item [{}] ({}) count [{}]", + log_handin_prefix, + i.item->GetItem()->Name, + i.item_id, + i.count + ) + ); + } + return removed; + } + ), + m_hand_in.items.end() + ); + } + + // log successful hand-in items + if (!log_entries.empty()) { + for (const auto& log : log_entries) { + LogNpcHandin("{}", log); + } + } + + // decrement successful hand-in money from current hand-in bucket + if (h.money.platinum > 0 || h.money.gold > 0 || h.money.silver > 0 || h.money.copper > 0) { + LogNpcHandin( + "{} Hand-in success, removing money p [{}] g [{}] s [{}] c [{}]", + log_handin_prefix, + h.money.platinum, + h.money.gold, + h.money.silver, + h.money.copper + ); + m_hand_in.money.platinum -= h.money.platinum; + m_hand_in.money.gold -= h.money.gold; + m_hand_in.money.silver -= h.money.silver; + m_hand_in.money.copper -= h.money.copper; + } + + LogNpcHandin( + "{} > End of hand-in | requirement_met [{}] item_count [{}] platinum [{}] gold [{}] silver [{}] copper [{}]", + log_handin_prefix, + requirement_met, + m_hand_in.items.size(), + m_hand_in.money.platinum, + m_hand_in.money.gold, + m_hand_in.money.silver, + m_hand_in.money.copper + ); + for (const auto &i: m_hand_in.items) { + LogNpcHandin( + "{} Hand-in success, item [{}] ({}) count [{}]", + log_handin_prefix, + i.item->GetItem()->Name, + i.item_id, + i.count + ); + } + } + + return requirement_met; +} + +NPC::Handin NPC::ReturnHandinItems(Client *c) +{ + // player event + std::vector handin_items; + PlayerEvent::HandinMoney handin_money{}; + std::vector return_items; + PlayerEvent::HandinMoney return_money{}; + for (const auto& i : m_hand_in.original_items) { + if (i.item && i.item->GetItem()) { + handin_items.emplace_back( + PlayerEvent::HandinEntry{ + .item_id = i.item->GetID(), + .item_name = i.item->GetItem()->Name, + .augment_ids = i.item->GetAugmentIDs(), + .augment_names = i.item->GetAugmentNames(), + .charges = std::max(static_cast(i.item->GetCharges()), static_cast(1)) + } + ); + } + } + + auto returned = m_hand_in; + + // check if any money was handed in + if (m_hand_in.original_money.platinum > 0 || + m_hand_in.original_money.gold > 0 || + m_hand_in.original_money.silver > 0 || + m_hand_in.original_money.copper > 0 + ) { + handin_money.copper = m_hand_in.original_money.copper; + handin_money.silver = m_hand_in.original_money.silver; + handin_money.gold = m_hand_in.original_money.gold; + handin_money.platinum = m_hand_in.original_money.platinum; + } + + bool returned_handin = false; + m_hand_in.items.erase( + std::remove_if( + m_hand_in.items.begin(), + m_hand_in.items.end(), + [&](auto& i) { + if (i.item && i.item->GetItem() && !i.is_multiquest_item) { + return_items.emplace_back( + PlayerEvent::HandinEntry{ + .item_id = i.item->GetID(), + .item_name = i.item->GetItem()->Name, + .augment_ids = i.item->GetAugmentIDs(), + .augment_names = i.item->GetAugmentNames(), + .charges = std::max(static_cast(i.item->GetCharges()), static_cast(1)) + } + ); + + c->PushItemOnCursor(*i.item, true); + LogNpcHandin("Hand-in failed, returning item [{}]", i.item->GetItem()->Name); + + // Safely delete the item + safe_delete(i.item); + returned_handin = true; + return true; // Mark this item for removal + } + return false; + } + ), + m_hand_in.items.end() + ); + + // check if any money was handed in + bool money_handed = m_hand_in.money.platinum > 0 || + m_hand_in.money.gold > 0 || + m_hand_in.money.silver > 0 || + m_hand_in.money.copper > 0; + if (money_handed) { + c->AddMoneyToPP( + m_hand_in.money.copper, + m_hand_in.money.silver, + m_hand_in.money.gold, + m_hand_in.money.platinum, + true + ); + returned_handin = true; + LogNpcHandin( + "Hand-in failed, returning money p [{}] g [{}] s [{}] c [{}]", + m_hand_in.money.platinum, + m_hand_in.money.gold, + m_hand_in.money.silver, + m_hand_in.money.copper + ); + + // player event + return_money.copper = m_hand_in.money.copper; + return_money.silver = m_hand_in.money.silver; + return_money.gold = m_hand_in.money.gold; + return_money.platinum = m_hand_in.money.platinum; + } + + m_has_processed_handin_return = returned_handin; + + if (returned_handin) { + Say( + fmt::format( + "I have no need for this {}, you can have it back.", + c->GetCleanName() + ).c_str() + ); + } + + const bool handed_in_money = ( + handin_money.platinum > 0 || + handin_money.gold > 0 || + handin_money.silver > 0 || + handin_money.copper > 0 + ); + const bool event_has_data_to_record = !handin_items.empty() || handed_in_money; + + if (player_event_logs.IsEventEnabled(PlayerEvent::NPC_HANDIN) && event_has_data_to_record) { + auto e = PlayerEvent::HandinEvent{ + .npc_id = GetNPCTypeID(), + .npc_name = GetCleanName(), + .handin_items = handin_items, + .handin_money = handin_money, + .return_items = return_items, + .return_money = return_money, + .is_quest_handin = parse->HasQuestSub(GetNPCTypeID(), EVENT_TRADE) + }; + + RecordPlayerEventLogWithClient(c, PlayerEvent::NPC_HANDIN, e); + } + + return returned; +} + +void NPC::ResetHandin() +{ + m_has_processed_handin_return = false; + m_handin_started = false; + if (!IsMultiQuestEnabled()) { + for (auto &i: m_hand_in.items) { + safe_delete(i.item); + } + + m_hand_in = {}; + } +} diff --git a/zone/npc.h b/zone/npc.h index 20b6f74f6f..3f6d501165 100644 --- a/zone/npc.h +++ b/zone/npc.h @@ -558,6 +558,46 @@ class NPC : public Mob bool CanPathTo(float x, float y, float z); void DoNpcToNpcAggroScan(); + + bool CanPetTakeItem(const EQ::ItemInstance *inst); + + struct HandinEntry { + std::string item_id = "0"; + uint16 count = 0; + const EQ::ItemInstance *item = nullptr; + bool is_multiquest_item = false; // state + }; + + struct HandinMoney { + uint32 platinum = 0; + uint32 gold = 0; + uint32 silver = 0; + uint32 copper = 0; + }; + + struct Handin { + std::vector original_items = {}; // this is what the player originally handed in, never modified + std::vector items = {}; // items can be removed from this set as successful handins are made + HandinMoney original_money = {}; // this is what the player originally handed in, never modified + HandinMoney money = {}; // money can be removed from this set as successful handins are made + }; + + // NPC Hand-in + bool IsMultiQuestEnabled() { return m_multiquest_enabled; } + void MultiQuestEnable() { m_multiquest_enabled = true; } + bool IsGuildmasterForClient(Client *c); + bool CheckHandin( + Client *c, + std::map handin, + std::map required, + std::vector items + ); + Handin ReturnHandinItems(Client *c); + void ResetHandin(); + bool HasProcessedHandinReturn() { return m_has_processed_handin_return; } + bool HandinStarted() { return m_handin_started; } + + protected: void HandleRoambox(); @@ -699,6 +739,15 @@ class NPC : public Mob bool raid_target; bool ignore_despawn; //NPCs with this set to 1 will ignore the despawn value in spawngroup + // NPC Hand-in + bool m_multiquest_enabled = false; + bool m_handin_started = false; + bool m_has_processed_handin_return = false; + + // this is the working handin data from the player + // items can be decremented from this as each successful + // check is ran in scripts, the remainder is what is returned + Handin m_hand_in = {}; private: uint32 m_loottable_id; diff --git a/zone/perl_npc.cpp b/zone/perl_npc.cpp index b47b8c0e8d..0000b974be 100644 --- a/zone/perl_npc.cpp +++ b/zone/perl_npc.cpp @@ -796,6 +796,85 @@ void Perl_NPC_DescribeSpecialAbilities(NPC* self, Client* c) self->DescribeSpecialAbilities(c); } +bool Perl_NPC_IsMultiQuestEnabled(NPC* self) +{ + return self->IsMultiQuestEnabled(); +} + +void Perl_NPC_MultiQuestEnable(NPC* self) +{ + self->MultiQuestEnable(); +} + +bool Perl_NPC_CheckHandin( + NPC* self, + Client* c, + perl::reference handin_ref, + perl::reference required_ref, + perl::array items_ref +) +{ + perl::hash handin = handin_ref; + perl::hash required = required_ref; + + std::map handin_map; + std::map required_map; + std::vector items; + + for (auto e: handin) { + if (!e.first) { + continue; + } + + if (Strings::EqualFold(e.first, "0")) { + continue; + } + + LogNpcHandinDetail("Handin key [{}] value [{}]", e.first, handin.at(e.first).c_str()); + + const uint32 count = static_cast(handin.at(e.first)); + handin_map[e.first] = count; + } + + for (auto e: required) { + if (!e.first) { + continue; + } + + if (Strings::EqualFold(e.first, "0")) { + continue; + } + + LogNpcHandinDetail("Required key [{}] value [{}]", e.first, required.at(e.first).c_str()); + + const uint32 count = static_cast(required.at(e.first)); + required_map[e.first] = count; + } + + for (auto e : items_ref) { + const EQ::ItemInstance* i = static_cast(e); + if (!i) { + continue; + } + + items.emplace_back(i); + + LogNpcHandinDetail( + "Item instance [{}] ({}) UUID ({}) added to handin list", + i->GetItem()->Name, + i->GetItem()->ID, + i->GetSerialNumber() + ); + } + + return self->CheckHandin(c, handin_map, required_map, items); +} + +void Perl_NPC_ReturnHandinItems(NPC *self, Client* c) +{ + self->ReturnHandinItems(c); +} + void perl_register_npc() { perl::interpreter perl(PERL_GET_THX); @@ -827,6 +906,7 @@ void perl_register_npc() package.add("CalculateNewWaypoint", &Perl_NPC_CalculateNewWaypoint); package.add("ChangeLastName", &Perl_NPC_ChangeLastName); package.add("CheckNPCFactionAlly", &Perl_NPC_CheckNPCFactionAlly); + package.add("CheckHandin", &Perl_NPC_CheckHandin); package.add("ClearItemList", &Perl_NPC_ClearLootItems); package.add("ClearLastName", &Perl_NPC_ClearLastName); package.add("CountItem", &Perl_NPC_CountItem); @@ -892,7 +972,8 @@ void perl_register_npc() package.add("IsGuarding", &Perl_NPC_IsGuarding); package.add("IsLDoNLocked", &Perl_NPC_IsLDoNLocked); package.add("IsLDoNTrapped", &Perl_NPC_IsLDoNTrapped); - package.add("IsLDoNTrapDetected", &Perl_NPC_IsLDoNTrapDetected);; + package.add("IsLDoNTrapDetected", &Perl_NPC_IsLDoNTrapDetected); + package.add("IsMultiQuestEnabled", &Perl_NPC_IsMultiQuestEnabled); package.add("IsOnHatelist", &Perl_NPC_IsOnHatelist); package.add("IsRaidTarget", &Perl_NPC_IsRaidTarget); package.add("IsRareSpawn", &Perl_NPC_IsRareSpawn); @@ -920,6 +1001,7 @@ void perl_register_npc() package.add("RemoveMeleeProc", &Perl_NPC_RemoveMeleeProc); package.add("RemoveRangedProc", &Perl_NPC_RemoveRangedProc); package.add("ResumeWandering", &Perl_NPC_ResumeWandering); + package.add("ReturnHandinItems", &Perl_NPC_ReturnHandinItems); package.add("SaveGuardSpot", (void(*)(NPC*))&Perl_NPC_SaveGuardSpot); package.add("SaveGuardSpot", (void(*)(NPC*, bool))&Perl_NPC_SaveGuardSpot); package.add("SaveGuardSpot", (void(*)(NPC*, float, float, float, float))&Perl_NPC_SaveGuardSpot); @@ -935,6 +1017,7 @@ void perl_register_npc() package.add("SetLDoNTrapDetected", &Perl_NPC_SetLDoNTrapDetected); package.add("SetLDoNTrapSpellID", &Perl_NPC_SetLDoNTrapSpellID); package.add("SetLDoNTrapType", &Perl_NPC_SetLDoNTrapType); + package.add("MultiQuestEnable", &Perl_NPC_MultiQuestEnable); package.add("SetNPCAggro", &Perl_NPC_SetNPCAggro); package.add("SetGold", &Perl_NPC_SetGold); package.add("SetGrid", &Perl_NPC_SetGrid); diff --git a/zone/questmgr.cpp b/zone/questmgr.cpp index cf77e503e0..ba70cfb206 100644 --- a/zone/questmgr.cpp +++ b/zone/questmgr.cpp @@ -1262,50 +1262,7 @@ bool QuestManager::isdisctome(uint32 item_id) { return false; } - if (!item->IsClassCommon() || item->ItemType != EQ::item::ItemTypeSpell) { - return false; - } - - //Need a way to determine the difference between a spell and a tome - //so they cant turn in a spell and get it as a discipline - //this is kinda a hack: - - const std::string item_name = item->Name; - - if ( - !Strings::BeginsWith(item_name, "Tome of ") && - !Strings::BeginsWith(item_name, "Skill: ") - ) { - return false; - } - - //we know for sure none of the int casters get disciplines - uint32 class_bit = 0; - class_bit |= 1 << (Class::Wizard - 1); - class_bit |= 1 << (Class::Enchanter - 1); - class_bit |= 1 << (Class::Magician - 1); - class_bit |= 1 << (Class::Necromancer - 1); - if (item->Classes & class_bit) { - return false; - } - - const auto& spell_id = static_cast(item->Scroll.Effect); - if (!IsValidSpell(spell_id)) { - return false; - } - - //we know for sure none of the int casters get disciplines - const auto& spell = spells[spell_id]; - if( - spell.classes[Class::Wizard - 1] != 255 && - spell.classes[Class::Enchanter - 1] != 255 && - spell.classes[Class::Magician - 1] != 255 && - spell.classes[Class::Necromancer - 1] != 255 - ) { - return false; - } - - return true; + return IsDisciplineTome(item); } std::string QuestManager::getracename(uint16 race_id) { diff --git a/zone/trading.cpp b/zone/trading.cpp index ac36f014fb..c2c0d826db 100644 --- a/zone/trading.cpp +++ b/zone/trading.cpp @@ -320,7 +320,11 @@ void Client::ResetTrade() { } void Client::FinishTrade(Mob* tradingWith, bool finalizer, void* event_entry, std::list* event_details) { - if(tradingWith && tradingWith->IsClient()) { + if (!tradingWith) { + return; + } + + if (tradingWith->IsClient()) { Client * other = tradingWith->CastToClient(); PlayerLogTrade_Struct * qs_audit = nullptr; bool qs_log = false; @@ -663,8 +667,7 @@ void Client::FinishTrade(Mob* tradingWith, bool finalizer, void* event_entry, st //Do not reset the trade here, done by the caller. } } - else if(tradingWith && tradingWith->IsNPC()) { - NPCHandinEventLog(trade, tradingWith->CastToNPC()); + else if(tradingWith->IsNPC()) { QSPlayerLogHandin_Struct* qs_audit = nullptr; bool qs_log = false; @@ -741,7 +744,6 @@ void Client::FinishTrade(Mob* tradingWith, bool finalizer, void* event_entry, st bool quest_npc = false; if (parse->HasQuestSub(tradingWith->GetNPCTypeID(), EVENT_TRADE)) { - // This is a quest NPC quest_npc = true; } @@ -757,19 +759,18 @@ void Client::FinishTrade(Mob* tradingWith, bool finalizer, void* event_entry, st if (RuleB(TaskSystem, EnableTaskSystem)) { if (UpdateTasksOnDeliver(items, *trade, tradingWith->CastToNPC())) { - if (!tradingWith->IsMoving()) + if (!tradingWith->IsMoving()) { tradingWith->FaceTarget(this); + } EVENT_ITEM_ScriptStopReturn(); - } } // Regardless of quest or non-quest NPC - No in combat trade completion // is allowed. - if (tradingWith->CheckAggro(this)) - { - for (EQ::ItemInstance* inst : items) { + if (tradingWith->CheckAggro(this)) { + for (EQ::ItemInstance *inst: items) { if (!inst || !inst->GetItem()) { continue; } @@ -780,9 +781,8 @@ void Client::FinishTrade(Mob* tradingWith, bool finalizer, void* event_entry, st } // Only enforce trade rules if the NPC doesn't have an EVENT_TRADE // subroutine. That overrides all. - else if (!quest_npc) - { - for (EQ::ItemInstance* inst : items) { + else if (!quest_npc) { + for (auto &inst: items) { if (!inst || !inst->GetItem()) { continue; } @@ -796,128 +796,116 @@ void Client::FinishTrade(Mob* tradingWith, bool finalizer, void* event_entry, st } } - const EQ::ItemData* item = inst->GetItem(); - const bool is_pet = _CLIENTPET(tradingWith) && tradingWith->GetPetType()<=petOther; - const bool is_quest_npc = tradingWith->CastToNPC()->IsQuestNPC(); - const bool restrict_quest_items_to_quest_npc = RuleB(NPC, ReturnQuestItemsFromNonQuestNPCs); - const bool pets_can_take_quest_items = RuleB(Pets, CanTakeQuestItems); - const bool is_pet_and_can_have_nodrop_items = (RuleB(Pets, CanTakeNoDrop) && is_pet); - const bool is_pet_and_can_have_quest_items = (pets_can_take_quest_items && is_pet); - // if it was not a NO DROP or Attuned item (or if a GM is trading), let the NPC have it - if (GetGM() || - (!restrict_quest_items_to_quest_npc || (is_quest_npc && item->IsQuestItem()) || !item->IsQuestItem()) && // If rule is enabled, return any quest items given to non-quest NPCs - (((item->NoDrop != 0 && !inst->IsAttuned()) || is_pet_and_can_have_nodrop_items) && - ((!item->IsQuestItem() || is_pet_and_can_have_quest_items || !is_pet)))) { + auto with = tradingWith->CastToNPC(); + const EQ::ItemData *item = inst->GetItem(); + + if (with->IsPetOwnerClient() && with->CanPetTakeItem(inst)) { // pets need to look inside bags and try to equip items found there if (item->IsClassBag() && item->BagSlots > 0) { - for (int16 bslot = EQ::invbag::SLOT_BEGIN; bslot < item->BagSlots; bslot++) { + // if an item inside the bag can't be given to the pet, keep the bag + bool keep_bag = false; + int item_count = 0; + for (int16 bslot = EQ::invbag::SLOT_BEGIN; bslot < item->BagSlots; bslot++) { const EQ::ItemInstance *baginst = inst->GetItem(bslot); - if (baginst) { - const EQ::ItemData *bagitem = baginst->GetItem(); - if (bagitem && (GetGM() || - (!restrict_quest_items_to_quest_npc || - (is_quest_npc && bagitem->IsQuestItem()) || !bagitem->IsQuestItem()) && - // If rule is enabled, return any quest items given to non-quest NPCs (inside bags) - (bagitem->NoDrop != 0 && !baginst->IsAttuned()) && - ((is_pet && (!bagitem->IsQuestItem() || pets_can_take_quest_items) || - !is_pet)))) { - - if (GetGM()) { - const std::string& item_link = database.CreateItemLink(bagitem->ID); - Message( - Chat::White, - fmt::format( - "Your GM flag allows you to give {} to {}.", - item_link, - GetTargetDescription(tradingWith) - ).c_str() - ); - } - - auto lde = LootdropEntriesRepository::NewNpcEntity(); - lde.equip_item = 1; - lde.item_charges = static_cast(baginst->GetCharges()); - - tradingWith->CastToNPC()->AddLootDrop( - bagitem, - lde, - true - ); - // Return quest items being traded to non-quest NPC when the rule is true - } else if (restrict_quest_items_to_quest_npc && (!is_quest_npc && bagitem->IsQuestItem())) { - tradingWith->SayString(TRADE_BACK, GetCleanName()); - PushItemOnCursor(*baginst, true); - Message(Chat::Red, "You can only trade quest items to quest NPCs."); - // Return quest items being traded to player pet when not allowed - } else if (is_pet && bagitem->IsQuestItem() && !pets_can_take_quest_items) { - tradingWith->SayString(TRADE_BACK, GetCleanName()); - PushItemOnCursor(*baginst, true); - Message(Chat::Red, "You cannot trade quest items with your pet."); - } else if (RuleB(NPC, ReturnNonQuestNoDropItems)) { - tradingWith->SayString(TRADE_BACK, GetCleanName()); - PushItemOnCursor(*baginst, true); - } + if (baginst && baginst->GetItem() && with->CanPetTakeItem(baginst)) { + // add item to pet's inventory + auto lde = LootdropEntriesRepository::NewNpcEntity(); + lde.equip_item = 1; + lde.item_charges = static_cast(baginst->GetCharges()); + with->AddLootDrop(baginst->GetItem(), lde, true); + inst->DeleteItem(bslot); + item_count++; + } + else { + keep_bag = true; } } - } else { + + // add item to pet's inventory + if (!keep_bag || item_count == 0) { + auto lde = LootdropEntriesRepository::NewNpcEntity(); + lde.equip_item = 1; + lde.item_charges = static_cast(inst->GetCharges()); + with->AddLootDrop(item, lde, true); + inst = nullptr; + } + } + else { + // add item to pet's inventory auto lde = LootdropEntriesRepository::NewNpcEntity(); lde.equip_item = 1; lde.item_charges = static_cast(inst->GetCharges()); - - tradingWith->CastToNPC()->AddLootDrop( - item, - lde, - true - ); + with->AddLootDrop(item, lde, true); + inst = nullptr; } } - // Return quest items being traded to non-quest NPC when the rule is true - else if (restrict_quest_items_to_quest_npc && (!is_quest_npc && item->IsQuestItem())) { - tradingWith->SayString(TRADE_BACK, GetCleanName()); - PushItemOnCursor(*inst, true); - Message(Chat::Red, "You can only trade quest items to quest NPCs."); - } - // Return quest items being traded to player pet when not allowed - else if (is_pet && item->IsQuestItem()) { - tradingWith->SayString(TRADE_BACK, GetCleanName()); - PushItemOnCursor(*inst, true); - Message(Chat::Red, "You cannot trade quest items with your pet."); - } - // Return NO DROP and Attuned items being handed into a non-quest NPC if the rule is true - else if (RuleB(NPC, ReturnNonQuestNoDropItems)) { - tradingWith->SayString(TRADE_BACK, GetCleanName()); - PushItemOnCursor(*inst, true); - } } } - char temp1[100] = { 0 }; - char temp2[100] = { 0 }; - snprintf(temp1, 100, "copper.%d", tradingWith->GetNPCTypeID()); - snprintf(temp2, 100, "%u", trade->cp); - parse->AddVar(temp1, temp2); - snprintf(temp1, 100, "silver.%d", tradingWith->GetNPCTypeID()); - snprintf(temp2, 100, "%u", trade->sp); - parse->AddVar(temp1, temp2); - snprintf(temp1, 100, "gold.%d", tradingWith->GetNPCTypeID()); - snprintf(temp2, 100, "%u", trade->gp); - parse->AddVar(temp1, temp2); - snprintf(temp1, 100, "platinum.%d", tradingWith->GetNPCTypeID()); - snprintf(temp2, 100, "%u", trade->pp); - parse->AddVar(temp1, temp2); + std::string currencies[] = {"copper", "silver", "gold", "platinum"}; + int32 amounts[] = {trade->cp, trade->sp, trade->gp, trade->pp}; + + for (int i = 0; i < 4; ++i) { + parse->AddVar( + fmt::format("{}.{}", currencies[i], tradingWith->GetNPCTypeID()).c_str(), + fmt::format("{}", amounts[i]).c_str() + ); + } if(tradingWith->GetAppearance() != eaDead) { tradingWith->FaceTarget(this); } + std::vector item_list(items.begin(), items.end()); + for (EQ::ItemInstance *inst: items) { + if (!inst || !inst->GetItem()) { + continue; + } + item_list.emplace_back(inst); + } + if (parse->HasQuestSub(tradingWith->GetNPCTypeID(), EVENT_TRADE)) { - std::vector item_list(items.begin(), items.end()); parse->EventNPC(EVENT_TRADE, tradingWith->CastToNPC(), this, "", 0, &item_list); + LogNpcHandinDetail("EVENT_TRADE triggered for NPC [{}]", tradingWith->GetNPCTypeID()); } - for(int i = 0; i < 4; ++i) { - if(insts[i]) { - safe_delete(insts[i]); + auto handin_npc = tradingWith->CastToNPC(); + + // this is a catch-all return for items that weren't consumed by the EVENT_TRADE subroutine + // it's possible we have a quest NPC that doesn't have an EVENT_TRADE subroutine + // we can't double fire the ReturnHandinItems() event, so we need to check if it's already been processed from EVENT_TRADE + if (!handin_npc->HasProcessedHandinReturn()) { + if (!handin_npc->HandinStarted()) { + LogNpcHandinDetail("EVENT_TRADE did not process handin, calling ReturnHandinItems() for NPC [{}]", tradingWith->GetNPCTypeID()); + std::map handin = { + {"copper", trade->cp}, + {"silver", trade->sp}, + {"gold", trade->gp}, + {"platinum", trade->pp} + }; + std::vector list(items.begin(), items.end()); + for (EQ::ItemInstance *inst: items) { + if (!inst || !inst->GetItem()) { + continue; + } + + std::string item_id = fmt::format("{}", inst->GetItem()->ID); + handin[item_id] += inst->GetCharges(); + item_list.emplace_back(inst); + } + + handin_npc->CheckHandin(this, handin, {}, list); + } + + handin_npc->ReturnHandinItems(this); + LogNpcHandin("ReturnHandinItems() called for NPC [{}]", handin_npc->GetNPCTypeID()); + } + + handin_npc->ResetHandin(); + + for (auto &inst: insts) { + if (inst) { + safe_delete(inst); } } } diff --git a/zone/zone_cli.cpp b/zone/zone_cli.cpp index 5739aa6ca1..a1db754ca8 100644 --- a/zone/zone_cli.cpp +++ b/zone/zone_cli.cpp @@ -12,6 +12,11 @@ bool ZoneCLI::RanSidecarCommand(int argc, char **argv) return argc > 1 && (strstr(argv[1], "sidecar:") != nullptr); } +bool ZoneCLI::RanTestCommand(int argc, char **argv) +{ + return argc > 1 && (strstr(argv[1], "tests:") != nullptr); +} + void ZoneCLI::CommandHandler(int argc, char **argv) { if (argc == 1) { return; } @@ -25,8 +30,10 @@ void ZoneCLI::CommandHandler(int argc, char **argv) // Register commands function_map["sidecar:serve-http"] = &ZoneCLI::SidecarServeHttp; + function_map["tests:npc-handins"] = &ZoneCLI::NpcHandins; EQEmuCommand::HandleMenu(function_map, cmd, argc, argv); } +#include "cli/npc_handins.cpp" #include "cli/sidecar_serve_http.cpp" diff --git a/zone/zone_cli.h b/zone/zone_cli.h index e9ed183672..cb73078aeb 100644 --- a/zone/zone_cli.h +++ b/zone/zone_cli.h @@ -7,8 +7,10 @@ class ZoneCLI { public: static void CommandHandler(int argc, char **argv); static void SidecarServeHttp(int argc, char **argv, argh::parser &cmd, std::string &description); + static void NpcHandins(int argc, char **argv, argh::parser &cmd, std::string &description); static bool RanConsoleCommand(int argc, char **argv); static bool RanSidecarCommand(int argc, char **argv); + static bool RanTestCommand(int argc, char **argv); }; diff --git a/zone/zonedb.cpp b/zone/zonedb.cpp index 3f0ac20e8c..d8323160fa 100644 --- a/zone/zonedb.cpp +++ b/zone/zonedb.cpp @@ -1905,6 +1905,7 @@ const NPCType *ZoneDatabase::LoadNPCTypesData(uint32 npc_type_id, bool bulk_load t->heroic_strikethrough = n.heroic_strikethrough; t->faction_amount = n.faction_amount; t->keeps_sold_items = n.keeps_sold_items; + t->multiquest_enabled = n.multiquest_enabled != 0; // If NPC with duplicate NPC id already in table, // free item we attempted to add. diff --git a/zone/zonedump.h b/zone/zonedump.h index 52dbb5b3ed..cefa2574d3 100644 --- a/zone/zonedump.h +++ b/zone/zonedump.h @@ -155,7 +155,8 @@ struct NPCType int heroic_strikethrough; bool keeps_sold_items; bool is_parcel_merchant; - uint8 greed; + uint8 greed; + bool multiquest_enabled; }; #pragma pack()