#include "mod_dragon_legacy.h" #include "Chat.h" #include "CommandScript.h" #include "Config.h" #include "Creature.h" #include "DatabaseEnv.h" #include "Player.h" #include "ScriptedGossip.h" #include "ScriptMgr.h" #include "SpellScript.h" using namespace Acore::ChatCommands; namespace { constexpr uint32 GOSSIP_ACTION_LEARN_DRAGON_FORM = 1; constexpr uint32 DRAGON_LEGACY_NPC_TEXT = 901000; // Dragon Form (900000) and Whelp Breath (900001) are from-scratch spell IDs with no entry in // the client's own Spell.dbc -- confirmed in-game that learning them via Player::learnSpell() // persists to character_spell, but the client has nothing to render/cast for them (no name, no // icon, no macro resolution). Unlock is tracked here instead, and Dragon Form is applied via a // triggered CastSpell from the .dragon-rawr command rather than asking the player to cast a // "known" spell themselves -- see modules/mod-dragon-legacy/PLAN.md ("v1.1: spell -> command // pivot") for the full writeup and the same lesson learned independently in mod-waygate-network. bool IsDragonLegacyUnlocked(Player* player) { QueryResult result = CharacterDatabase.Query( "SELECT 1 FROM mod_dragon_legacy_unlocked WHERE guid = {}", player->GetGUID().GetCounter()); return result != nullptr; } } class DragonLegacy_NPC : public CreatureScript { public: DragonLegacy_NPC() : CreatureScript("DragonLegacy_NPC") { } bool OnGossipHello(Player* player, Creature* creature) override { ClearGossipMenuFor(player); if (sConfigMgr->GetOption("DragonLegacy.Enable", true) && !IsDragonLegacyUnlocked(player)) { AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Teach me the way of the dragon!", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_LEARN_DRAGON_FORM); } SendGossipMenuFor(player, DRAGON_LEGACY_NPC_TEXT, creature->GetGUID()); return true; } bool OnGossipSelect(Player* player, Creature* /*creature*/, uint32 sender, uint32 action) override { if (sender != GOSSIP_SENDER_MAIN) return false; CloseGossipMenuFor(player); if (action == GOSSIP_ACTION_LEARN_DRAGON_FORM && sConfigMgr->GetOption("DragonLegacy.Enable", true) && !IsDragonLegacyUnlocked(player)) { CharacterDatabase.Execute( "INSERT INTO mod_dragon_legacy_unlocked (guid) VALUES ({})", player->GetGUID().GetCounter()); ChatHandler(player->GetSession()).PSendSysMessage( "You have unlocked the way of the dragon. Use .dragon-rawr to transform, and " ".dragon-rawr breath to unleash Whelp Breath on your target."); } return true; } }; class spell_dragon_legacy_whelp_breath : public SpellScript { PrepareSpellScript(spell_dragon_legacy_whelp_breath); SpellCastResult CheckDragonForm() { if (!GetCaster()->HasAura(SPELL_DRAGON_FORM)) return SPELL_FAILED_ONLY_SHAPESHIFT; return SPELL_CAST_OK; } void Register() override { OnCheckCast += SpellCheckCastFn(spell_dragon_legacy_whelp_breath::CheckDragonForm); } }; class DragonLegacyCommandScript : public CommandScript { public: DragonLegacyCommandScript() : CommandScript("DragonLegacyCommandScript") { } ChatCommandTable GetCommands() const override { static ChatCommandTable dragonRawrCommandTable = { { "breath", HandleDragonBreathCommand, SEC_PLAYER, Console::No }, { "testvehicle", HandleDragonTestVehicleCommand, SEC_GAMEMASTER, Console::No }, { "testvehicle2", HandleDragonTestVehicle2Command, SEC_GAMEMASTER, Console::No }, { "testvehicle3", HandleDragonTestVehicle3Command, SEC_GAMEMASTER, Console::No }, { "testvehicle4", HandleDragonTestVehicle4Command, SEC_GAMEMASTER, Console::No }, { "", HandleDragonFormCommand, SEC_PLAYER, Console::No }, }; static ChatCommandTable commandTable = { { "dragon-rawr", dragonRawrCommandTable }, }; return commandTable; } static bool HandleDragonFormCommand(ChatHandler* handler) { Player* player = handler->GetPlayer(); if (!player) return true; if (!sConfigMgr->GetOption("DragonLegacy.Enable", true)) { handler->PSendSysMessage("The way of the dragon is currently disabled."); return true; } if (player->HasAura(SPELL_DRAGON_FORM)) { player->RemoveAurasDueToSpell(SPELL_DRAGON_FORM); return true; } if (!IsDragonLegacyUnlocked(player)) { handler->PSendSysMessage("You have not learned the way of the dragon yet."); return true; } player->CastSpell(player, SPELL_DRAGON_FORM, true); return true; } static bool HandleDragonBreathCommand(ChatHandler* handler) { Player* player = handler->GetPlayer(); if (!player) return true; if (!player->HasAura(SPELL_DRAGON_FORM)) { handler->PSendSysMessage("You must take dragon form before you can use Whelp Breath."); return true; } Unit* target = player->GetSelectedUnit(); if (!target) { handler->PSendSysMessage("You need a target to breathe fire at."); return true; } player->CastSpell(target, SPELL_WHELP_BREATH, true); return true; } // Shared exit path for all testvehicle* commands below -- returns true if the player was on a // test vehicle and has now been taken off it (and it despawned), false if they weren't on one. // Always restores native object scale on exit -- harmless no-op for testvehicle/testvehicle2 // (which never touch it), undoes testvehicle3's shrink-to-hide trick. static bool ExitTestVehicleIfMounted(Player* player, ChatHandler* handler) { if (!player->GetVehicle()) return false; Unit* base = player->GetVehicleBase(); player->ExitVehicle(); player->SetObjectScale(player->GetNativeObjectScale()); if (base) if (Creature* baseCreature = base->ToCreature()) baseCreature->DespawnOrUnsummon(); handler->PSendSysMessage("Exited the test vehicle."); return true; } // v2 prototype only, see PLAN.md "v2: vehicle-based full transform" -- summons a vehicle // creature and seats the player in it via the same public API core scripts use // (Unit::EnterVehicle), to prove the possess + bar mechanic works before building the real // thing. No lockout logic yet. GM-only. Shared by testvehicle and testvehicle2. static bool ToggleTestVehicle(ChatHandler* handler, uint32 creatureEntry, char const* boardedMessage) { Player* player = handler->GetPlayer(); if (!player) return true; if (ExitTestVehicleIfMounted(player, handler)) return true; Creature* vehicle = player->SummonCreature(creatureEntry, player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), player->GetOrientation(), TEMPSUMMON_TIMED_DESPAWN, 600000); if (!vehicle) { handler->PSendSysMessage("Could not summon the test vehicle."); return true; } // Mirrors a workaround in the real Oculus drake AI (oculus.cpp, npc_oculus_drakeAI) for a // known "summoned in air with wrong animation state" issue -- trying it here in case it // also fixes the seethrough rendering seen on the drake test. vehicle->SetDisableGravity(false); vehicle->SetHover(false); player->EnterVehicle(vehicle, 0); handler->PSendSysMessage(boardedMessage); return true; } static bool HandleDragonTestVehicleCommand(ChatHandler* handler) { return ToggleTestVehicle(handler, NPC_TEST_RUBY_DRAKE, "Boarded the test drake -- check your action bar and whether it renders solid."); } // Control group: a real, well-tested, non-dragon/non-flying vehicle, to check whether the // seethrough issue is specific to the drake/flying-model or a general artifact of how this // test summons and boards vehicles. static bool HandleDragonTestVehicle2Command(ChatHandler* handler) { return ToggleTestVehicle(handler, NPC_TEST_DEMOLISHER, "Boarded the test demolisher (control group, non-dragon) -- check whether it renders solid."); } // Same drake model as testvehicle, but the vehicle container is swapped at runtime // (Unit::CreateVehicleKit) to VEHICLE_TEST_HIDE_PASSENGER, whose seat has // VEHICLE_SEAT_FLAG_HIDE_PASSENGER set -- tests whether the rider can be hidden on the drake's // model, i.e. "transform into" rather than "sit on top of." static bool HandleDragonTestVehicle3Command(ChatHandler* handler) { Player* player = handler->GetPlayer(); if (!player) return true; if (ExitTestVehicleIfMounted(player, handler)) return true; Creature* drake = player->SummonCreature(NPC_TEST_RUBY_DRAKE, player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), player->GetOrientation(), TEMPSUMMON_TIMED_DESPAWN, 600000); if (!drake) { handler->PSendSysMessage("Could not summon the test drake."); return true; } drake->SetDisableGravity(false); drake->SetHover(false); drake->RemoveVehicleKit(); drake->CreateVehicleKit(VEHICLE_TEST_HIDE_PASSENGER, NPC_TEST_RUBY_DRAKE); player->EnterVehicle(drake, 0); // VEHICLE_SEAT_FLAG_HIDE_PASSENGER alone didn't hide the rider in-game (2026-08-14) -- the // WotLK client appears to always render the pilot's own model locally regardless of seat // flags. Shrinking the player to near-zero scale is a fully server-controlled fallback that // achieves the same visual result without depending on client-side seat behavior. Restored // to native scale in ExitTestVehicleIfMounted. player->SetObjectScale(0.01f); handler->PSendSysMessage("Boarded the test drake with the rider shrunk -- check whether you're effectively invisible on it."); return true; } // Boards the module's own cloned test dragon (901001, see dragon_legacy_vehicle_test.sql) // instead of the real Ruby Drake -- proves cloning into the module's reserved range works, and // that the display model can be freely swapped independent of the source it was cloned from // (this clone deliberately uses a different model than Ruby Drake's own). static bool HandleDragonTestVehicle4Command(ChatHandler* handler) { return ToggleTestVehicle(handler, NPC_TEST_DRAGON_CLONE, "Boarded the cloned test dragon (different model) -- check whether the swapped display renders correctly."); } }; class DragonLegacy_Player : public PlayerScript { public: DragonLegacy_Player() : PlayerScript("DragonLegacy_Player") { } void OnPlayerBeforeLogout(Player* player) override { if (player->HasAura(SPELL_DRAGON_FORM)) player->RemoveAurasDueToSpell(SPELL_DRAGON_FORM); } void OnPlayerDeleteFromDB(CharacterDatabaseTransaction trans, uint32 guid) override { // Otherwise a deleted character's guid can be recycled and the new character on that guid // would inherit an unlock it never earned. trans->Append("DELETE FROM mod_dragon_legacy_unlocked WHERE guid = {}", guid); } }; void AddSC_mod_dragon_legacy() { new DragonLegacy_NPC(); new DragonLegacy_Player(); new DragonLegacyCommandScript(); RegisterSpellScript(spell_dragon_legacy_whelp_breath); }