diff --git a/sql/updates/characters/master/.gitignore b/sql/updates/characters/master/.gitignore deleted file mode 100644 index 9ef72e70b..000000000 --- a/sql/updates/characters/master/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ - -build*/ -.directory -.mailmap -*.orig -*.rej -*~ -.hg/ -*.kdev* -.DS_Store -CMakeLists.txt.user -*.bak -*.patch -*.diff -*.REMOTE.* -*.BACKUP.* -*.BASE.* -*.LOCAL.* -nbproject/* -.idea/* -.browse.VC* -.vscode -cmake-build-*/ -.vs diff --git a/sql/updates/characters/master/2026_10_03_00_characters_housing_purchase.sql b/sql/updates/characters/master/2026_10_03_00_characters_housing_purchase.sql new file mode 100644 index 000000000..cd3d3708c --- /dev/null +++ b/sql/updates/characters/master/2026_10_03_00_characters_housing_purchase.sql @@ -0,0 +1,314 @@ +-- Housing quest/purchase milestone. Apply to characters through the normal DB updater. +-- Additive schema only: no DROP, TRUNCATE, DELETE or imported TrinityCore housing schema. +-- Full GUIDs use an ASCII binary collation; character counters remain BIGINT UNSIGNED. + +CREATE TABLE IF NOT EXISTS `character_housing` ( + OwnerGuid bigint unsigned NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + CosmeticOwnerGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PlotID tinyint unsigned NOT NULL, + ExteriorMapID int unsigned NOT NULL, + HouseSettingFlags int unsigned NOT NULL DEFAULT 0, + ReservationTime bigint unsigned DEFAULT NULL, + PRIMARY KEY (HouseGuid), + UNIQUE KEY uq_owner (OwnerGuid), + UNIQUE KEY uq_plot (NeighborhoodGuid, ExteriorMapID, PlotID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_neighborhood` ( + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + OwnerGuid bigint unsigned NOT NULL, + Name varchar(127) NOT NULL DEFAULT '', + Type int unsigned NOT NULL DEFAULT 0, + Flags int unsigned NOT NULL DEFAULT 0, + IsPublic tinyint unsigned NOT NULL DEFAULT 0, + GuildId int unsigned DEFAULT NULL, + PRIMARY KEY (NeighborhoodGuid), + UNIQUE KEY uq_guild (GuildId), + KEY idx_owner (OwnerGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_plot_template` ( + PlotID tinyint unsigned NOT NULL, + ExteriorMapID int unsigned NOT NULL, + InteriorMapID int unsigned NOT NULL DEFAULT 2783, + DestinationX float NOT NULL DEFAULT 0, + DestinationY float NOT NULL DEFAULT 0, + DestinationZ float NOT NULL DEFAULT 0, + DestinationO float NOT NULL DEFAULT 0, + CornerstoneEntry int unsigned NOT NULL, + CornerstonePositionX float NOT NULL DEFAULT 0, + CornerstonePositionY float NOT NULL DEFAULT 0, + CornerstonePositionZ float NOT NULL DEFAULT 0, + CornerstoneRotationZ float NOT NULL DEFAULT 0, + HousePositionX float NOT NULL DEFAULT 0, + HousePositionY float NOT NULL DEFAULT 0, + HousePositionZ float NOT NULL DEFAULT 0, + HouseRotationZ float NOT NULL DEFAULT 0, + DoorEntry int unsigned NOT NULL DEFAULT 0, + Cost bigint unsigned NOT NULL, + VerifiedBuild int NOT NULL DEFAULT 0, + PRIMARY KEY (ExteriorMapID, PlotID, VerifiedBuild) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_plot_reservation` ( + ExteriorMapID int unsigned NOT NULL, + PlotID tinyint unsigned NOT NULL, + OwnerGuid bigint unsigned NOT NULL, + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + ReservedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + ExpiresAt timestamp NOT NULL, + PRIMARY KEY (NeighborhoodGuid, ExteriorMapID, PlotID), + UNIQUE KEY uq_owner (OwnerGuid), + KEY idx_expiry (ExpiresAt) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_plot_transaction` ( + TransactionID bigint unsigned NOT NULL AUTO_INCREMENT, + OwnerGuid bigint unsigned NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + OldPlotID tinyint unsigned NOT NULL, + NewPlotID tinyint unsigned NOT NULL, + ExteriorMapID int unsigned NOT NULL, + Cost bigint unsigned NOT NULL DEFAULT 0, + TransactionType tinyint unsigned NOT NULL, + CreatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (TransactionID), + KEY idx_owner_house (OwnerGuid, HouseGuid, TransactionID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `character_housing_map_context` ( + OwnerGuid bigint unsigned NOT NULL, + MapID int unsigned NOT NULL, + ContextGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (OwnerGuid, MapID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_map_instance` ( + InstanceID int unsigned NOT NULL AUTO_INCREMENT, + MapID int unsigned NOT NULL, + ContextGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (InstanceID), + UNIQUE KEY uq_map_context (MapID, ContextGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `character_housing_neighborhood_invite` ( + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + InviteeGuid bigint unsigned NOT NULL, + InviterGuid bigint unsigned NOT NULL, + CreatedAt bigint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (NeighborhoodGuid, InviteeGuid), + KEY idx_invitee (InviteeGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `character_housing_neighborhood_role` ( + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + CharacterGuid bigint unsigned NOT NULL, + Role tinyint unsigned NOT NULL DEFAULT 0, + GrantedBy bigint unsigned NOT NULL DEFAULT 0, + GrantedAt bigint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (NeighborhoodGuid, CharacterGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `character_housing_finder_ignore` ( + CharacterGuid bigint unsigned NOT NULL, + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + CreatedAt bigint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (CharacterGuid, NeighborhoodGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `character_housing_return_location` ( + OwnerGuid bigint unsigned NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + NeighborhoodGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + ExteriorMapID int unsigned NOT NULL, + PlotID tinyint unsigned NOT NULL, + PositionX float NOT NULL DEFAULT 0, + PositionY float NOT NULL DEFAULT 0, + PositionZ float NOT NULL DEFAULT 0, + Orientation float NOT NULL DEFAULT 0, + PRIMARY KEY (OwnerGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `character_housing_permission` ( + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + CharacterGuid bigint unsigned NOT NULL, + Permissions int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (HouseGuid, CharacterGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Empty entity/storage backing tables are needed by the existing house graph +-- and dashboard readers immediately after a purchase. No decor or room data is seeded. + +CREATE TABLE IF NOT EXISTS `housing_room_entity` ( + RoomGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + HouseRoomID int NOT NULL, + Flags int NOT NULL DEFAULT 0, + PositionX float NOT NULL DEFAULT 0, + PositionY float NOT NULL DEFAULT 0, + PositionZ float NOT NULL DEFAULT 0, + RotationX float NOT NULL DEFAULT 0, + RotationY float NOT NULL DEFAULT 0, + RotationZ float NOT NULL DEFAULT 0, + RotationW float NOT NULL DEFAULT 1, + Scale float NOT NULL DEFAULT 1, + AttachmentFlags tinyint unsigned NOT NULL DEFAULT 3, + FloorIndex int NOT NULL DEFAULT 0, + LayoutSlotIndex int unsigned DEFAULT NULL, + PRIMARY KEY (RoomGuid), + KEY idx_house (HouseGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_room_component_entity` ( + EntityGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + FileDataID int unsigned NOT NULL DEFAULT 0, + IsWMO tinyint unsigned NOT NULL DEFAULT 0, + IsRoom tinyint unsigned NOT NULL DEFAULT 0, + AttachParentGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + PositionX float NOT NULL DEFAULT 0, + PositionY float NOT NULL DEFAULT 0, + PositionZ float NOT NULL DEFAULT 0, + RotationX float NOT NULL DEFAULT 0, + RotationY float NOT NULL DEFAULT 0, + RotationZ float NOT NULL DEFAULT 0, + RotationW float NOT NULL DEFAULT 1, + Scale float NOT NULL DEFAULT 1, + AttachmentFlags tinyint unsigned NOT NULL DEFAULT 3, + RoomGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + RoomComponentOptionID int NOT NULL DEFAULT 0, + RoomComponentID int NOT NULL DEFAULT 0, + RoomComponentType tinyint unsigned NOT NULL DEFAULT 0, + HouseThemeID int NOT NULL DEFAULT 0, + RoomComponentTextureID int NOT NULL DEFAULT 0, + RoomComponentTypeParam int NOT NULL DEFAULT 0, + PRIMARY KEY (EntityGuid), + KEY idx_house_room (HouseGuid, RoomGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_room_connection` ( + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + RoomGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + RoomComponentID int NOT NULL, + OffsetX float NOT NULL DEFAULT 0, + OffsetY float NOT NULL DEFAULT 0, + OffsetZ float NOT NULL DEFAULT 0, + RoomComponentType tinyint unsigned NOT NULL DEFAULT 0, + AttachedRoomGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (HouseGuid, RoomGuid, RoomComponentID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_fixture_entity` ( + EntityGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + FileDataID int unsigned NOT NULL DEFAULT 0, + IsWMO tinyint unsigned NOT NULL DEFAULT 0, + IsRoom tinyint unsigned NOT NULL DEFAULT 0, + AttachParentGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + PositionX float NOT NULL DEFAULT 0, + PositionY float NOT NULL DEFAULT 0, + PositionZ float NOT NULL DEFAULT 0, + RotationX float NOT NULL DEFAULT 0, + RotationY float NOT NULL DEFAULT 0, + RotationZ float NOT NULL DEFAULT 0, + RotationW float NOT NULL DEFAULT 1, + Scale float NOT NULL DEFAULT 1, + AttachmentFlags tinyint unsigned NOT NULL DEFAULT 3, + ExteriorComponentID int NOT NULL DEFAULT 0, + HouseExteriorWmoDataID int NOT NULL DEFAULT 0, + ExteriorComponentHookID int NOT NULL DEFAULT 0, + ExteriorComponentType tinyint unsigned NOT NULL DEFAULT 0, + Size tinyint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (EntityGuid), + KEY idx_house (HouseGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_placed_decor` ( + DecorGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + EntityGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + FileDataID int unsigned NOT NULL DEFAULT 0, + IsWMO tinyint unsigned NOT NULL DEFAULT 0, + IsRoom tinyint unsigned NOT NULL DEFAULT 0, + AttachParentGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + PositionX float NOT NULL DEFAULT 0, + PositionY float NOT NULL DEFAULT 0, + PositionZ float NOT NULL DEFAULT 0, + RotationX float NOT NULL DEFAULT 0, + RotationY float NOT NULL DEFAULT 0, + RotationZ float NOT NULL DEFAULT 0, + RotationW float NOT NULL DEFAULT 1, + Scale float NOT NULL DEFAULT 1, + AttachmentFlags tinyint unsigned NOT NULL DEFAULT 3, + RoomGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + OwnerGuid bigint unsigned NOT NULL DEFAULT 0, + ParentHouseFixtureGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + PlacedComponentID int unsigned NOT NULL DEFAULT 0, + Flags tinyint unsigned NOT NULL DEFAULT 0, + DyeSlot0 int unsigned NOT NULL DEFAULT 0, + DyeSlot1 int unsigned NOT NULL DEFAULT 0, + DyeSlot2 int unsigned NOT NULL DEFAULT 0, + Locked tinyint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (DecorGuid), + KEY idx_house (HouseGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_decor_instance` ( + DecorGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + BattlenetAccountId int unsigned NOT NULL, + DecorID int unsigned NOT NULL, + HouseGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + PlacementStatus tinyint unsigned NOT NULL DEFAULT 0, + SourceType tinyint unsigned NOT NULL DEFAULT 0, + SourceValue varchar(255) NOT NULL DEFAULT '', + PRIMARY KEY (DecorGuid), + KEY idx_account_decor (BattlenetAccountId, DecorID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `account_housing_decor` ( + BattlenetAccountId int unsigned NOT NULL, + DecorId int unsigned NOT NULL, + Quantity int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (BattlenetAccountId, DecorId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_entity_quarantine` ( + EntityType varchar(32) NOT NULL, + EntityGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + Reason varchar(127) NOT NULL, + HitCount int unsigned NOT NULL DEFAULT 1, + PRIMARY KEY (EntityType, EntityGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_blueprint` ( + ID bigint unsigned NOT NULL AUTO_INCREMENT, + BnetAccountID int unsigned NOT NULL, + UUID varchar(127) NOT NULL, + Name varchar(127) NOT NULL DEFAULT '', + Type int unsigned NOT NULL DEFAULT 0, + DateCreated bigint NOT NULL DEFAULT 0, + DateDeleted bigint NOT NULL DEFAULT 0, + Flags tinyint unsigned NOT NULL DEFAULT 0, + DeleteReason tinyint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (ID), + KEY idx_account (BnetAccountID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_blueprint_item` ( + BlueprintID bigint unsigned NOT NULL, + ItemID int unsigned NOT NULL, + Quantity int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (BlueprintID, ItemID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `housing_deferred_decor_redemption` ( + BattlenetAccountId int unsigned NOT NULL, + TransactionID bigint unsigned NOT NULL, + DecorID int unsigned NOT NULL, + DecorGuid varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (BattlenetAccountId, TransactionID), + KEY idx_decor (DecorGuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/updates/world/master/.gitignore b/sql/updates/world/master/.gitignore deleted file mode 100644 index 9ef72e70b..000000000 --- a/sql/updates/world/master/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ - -build*/ -.directory -.mailmap -*.orig -*.rej -*~ -.hg/ -*.kdev* -.DS_Store -CMakeLists.txt.user -*.bak -*.patch -*.diff -*.REMOTE.* -*.BACKUP.* -*.BASE.* -*.LOCAL.* -nbproject/* -.idea/* -.browse.VC* -.vscode -cmake-build-*/ -.vs diff --git a/sql/updates/world/master/2026_10_03_02_world_housing_purchase.sql b/sql/updates/world/master/2026_10_03_02_world_housing_purchase.sql new file mode 100644 index 000000000..6e278edfa --- /dev/null +++ b/sql/updates/world/master/2026_10_03_02_world_housing_purchase.sql @@ -0,0 +1,25 @@ +-- Phase 1: expose the existing housing quest and native cornerstone purchase UI. +-- Requires the deployment's populated world database; does not fabricate quest +-- templates, objectives, NPC spawns or client-build-dependent display IDs. + +UPDATE creature_template SET npcflag = npcflag | 3, ScriptName = 'npc_housing_steward' +WHERE entry IN (233063, 233708, 256078); + +-- 91863 must be reachable directly through the explicit tutorial action. +-- Preserve all unrelated addon fields (mail, restrictions, script, rewards). +INSERT INTO quest_template_addon (ID, PrevQuestID) +SELECT ID, 0 FROM quest_template WHERE ID = 91863 +ON DUPLICATE KEY UPDATE PrevQuestID = VALUES(PrevQuestID); + +INSERT IGNORE INTO creature_queststarter (id, quest) +SELECT c.entry, q.ID FROM creature_template c JOIN quest_template q ON q.ID = 91863 +WHERE c.entry IN (233063, 233708); +INSERT IGNORE INTO creature_questender (id, quest) +SELECT c.entry, q.ID FROM creature_template c JOIN quest_template q ON q.ID = 91863 +WHERE c.entry IN (233063, 233708); + +-- The existing generic template is the visual fallback for DB2-specific entries. +UPDATE gameobject_template SET Data0 = 4, Data7 = 70 +WHERE entry = 457142 AND type = 43; + +-- Keep objective amounts/flags and exploration/location requirements untouched. diff --git a/src/server/game/Entities/GameObject/GameObject.cpp b/src/server/game/Entities/GameObject/GameObject.cpp index 54b40f91b..9c35df6e3 100644 --- a/src/server/game/Entities/GameObject/GameObject.cpp +++ b/src/server/game/Entities/GameObject/GameObject.cpp @@ -64,6 +64,8 @@ //WowCommunity #include "Garrison.h" #include "NPCPackets.h" +#include "HousingMgr.h" +#include "HousingTutorialSession.h" //WowCommunity void GameObjectTemplate::InitializeQueryData() @@ -3485,7 +3487,10 @@ void GameObject::Use(Unit* user, bool ignoreCastInProgress /*= false*/) WorldPackets::GameObject::GameObjectInteraction gameObjectUILink; gameObjectUILink.ObjectGUID = GetGUID(); - switch (GetGOInfo()->UILink.UILinkType) + // Data7 supersedes the deprecated Data0 on current client templates. + uint32 const uiLinkType = GetGOInfo()->UILink.PlayerInteractionType == 70 + ? 4 : GetGOInfo()->UILink.UILinkType; + switch (uiLinkType) { case 0: gameObjectUILink.InteractionType = PlayerInteractionType::AdventureJournal; @@ -3499,6 +3504,22 @@ void GameObject::Use(Unit* user, bool ignoreCastInProgress /*= false*/) case 3: gameObjectUILink.InteractionType = PlayerInteractionType::ItemInteraction; break; + case 4: + { + if (!sHousingMgr.IsPlayerOnHousingExterior(player) || + GetMap()->GetHousingPlotIdByCornerstoneGuid(GetGUID()) < 0 || + !player->IsWithinDistInMap(this, 10.0f)) + return; + player->PlayerTalkClass->GetInteractionData().StartInteraction( + GetGUID(), static_cast(70)); + WorldPackets::NPC::NPCInteractionOpenResult open; + open.Npc = GetGUID(); + open.InteractionType = static_cast(70); + open.Success = true; + player->SendDirectMessage(open.Write()); + Housing::CreditTutorialObjective(player, Housing::TutorialExplorePlotsCredit, GetGUID()); + return; + } default: break; } diff --git a/src/server/game/Handlers/HousingHandler.cpp b/src/server/game/Handlers/HousingHandler.cpp index 4b06e285a..c497ae11f 100644 --- a/src/server/game/Handlers/HousingHandler.cpp +++ b/src/server/game/Handlers/HousingHandler.cpp @@ -20,6 +20,8 @@ #include "HousingPackets.h" #include "HousingMapContext.h" #include "HousingDefines.h" +#include "HousingMgr.h" +#include "GameObject.h" #include "HousingDynamicEntity.h" #include "Log.h" #include "Map.h" @@ -457,17 +459,18 @@ void WorldSession::HandleNeighborhoodOpenCornerstoneUI(WorldPackets::Housing::Ne return; } - QueryResult plot = CharacterDatabase.PQuery( - "SELECT Cost FROM housing_plot_template WHERE PlotID = {} AND ExteriorMapID = {} LIMIT 1", - packet.PlotIndex, exteriorMapId); - if (!plot) + Housing::PlotState plot; + ObjectGuid const cornerstoneGuid = player->GetMap()->GetHousingPlotCornerstoneGuid(uint8(packet.PlotIndex)); + GameObject* cornerstone = player->GetMap()->GetGameObject(cornerstoneGuid); + if (!cornerstone || !player->IsWithinDistInMap(cornerstone, 10.0f) || + sHousingMgr.LoadPlot(player, uint8(packet.PlotIndex), plot) != Housing::Result::Success) { SendPacket(response.Write()); return; } - response.Cost = plot->Fetch()[0].GetUInt64(); - response.CornerstoneGUID = player->GetMap()->GetHousingPlotCornerstoneGuid(uint8(packet.PlotIndex)); + response.Cost = plot.Cost; + response.CornerstoneGUID = cornerstoneGuid; QueryResult neighborhood = CharacterDatabase.PQuery( "SELECT Name FROM housing_neighborhood WHERE NeighborhoodGuid = '{}' LIMIT 1", @@ -724,4 +727,3 @@ void WorldSession::ApplyHousingDecorMove(WorldPackets::Housing::HousingDecorMove response.Result = HOUSING_RESULT_SUCCESS; SendPacket(response.Write()); } - diff --git a/src/server/game/Housing/HousingMgr.cpp b/src/server/game/Housing/HousingMgr.cpp index 66ae3696e..1286330ce 100644 --- a/src/server/game/Housing/HousingMgr.cpp +++ b/src/server/game/Housing/HousingMgr.cpp @@ -6,12 +6,16 @@ #include "HousingMapContext.h" #include "Map.h" #include "DatabaseEnv.h" +#include "DB2Stores.h" +#include "GameObject.h" #include "Group.h" #include "Log.h" #include "ObjectAccessor.h" #include "Player.h" #include "SocialMgr.h" #include +#include +#include namespace { @@ -19,6 +23,13 @@ namespace // tables. Serialize reservation/commit transitions so duplicate client // requests cannot both pass the pre-commit checks on one worldserver. std::mutex HousingPlotTransactionMutex; + + bool IsNearCornerstone(Player const* player, uint8 plotId) + { + GameObject* cornerstone = player->GetMap()->GetGameObject( + player->GetMap()->GetHousingPlotCornerstoneGuid(plotId)); + return cornerstone && player->IsWithinDistInMap(cornerstone, 10.0f); + } } namespace Housing @@ -47,6 +58,56 @@ namespace Housing IsNeighborhoodGuid(player->GetMap()->GetHousingContextGuid()); } + bool Manager::InitializePlotTemplates(uint32 exteriorMapId) const + { + if (!IsExteriorMapId(exteriorMapId)) + return false; + + CharacterDatabaseTransaction transaction = CharacterDatabase.BeginTransaction(); + uint32 count = 0; + for (NeighborhoodPlotEntry const* entry : sNeighborhoodPlotStore) + { + NeighborhoodMapEntry const* neighborhood = sNeighborhoodMapStore.LookupEntry(entry->NeighborhoodMapID); + if (!neighborhood || neighborhood->MapID != int32(exteriorMapId) || + entry->PlotIndex < 0 || entry->PlotIndex > std::numeric_limits::max() || + entry->CornerstoneGameObjectID <= 0 || entry->Cost > uint64(std::numeric_limits::max()) || + !std::isfinite(entry->TeleportPosition.X) || !std::isfinite(entry->TeleportPosition.Y) || + !std::isfinite(entry->TeleportPosition.Z) || !std::isfinite(entry->TeleportFacing) || + !std::isfinite(entry->CornerstonePosition.X) || !std::isfinite(entry->CornerstonePosition.Y) || + !std::isfinite(entry->CornerstonePosition.Z) || !std::isfinite(entry->CornerstoneRotation.Z) || + !std::isfinite(entry->HousePosition.X) || !std::isfinite(entry->HousePosition.Y) || + !std::isfinite(entry->HousePosition.Z) || !std::isfinite(entry->HouseRotation.Z)) + continue; + + // Build 0 is the current DB2-derived fallback. Explicit verified SQL overrides + // have higher priority. Never import another core's coordinates or prices. + transaction->PAppend( + "INSERT INTO housing_plot_template (PlotID, ExteriorMapID, InteriorMapID, " + "DestinationX, DestinationY, DestinationZ, DestinationO, CornerstoneEntry, " + "CornerstonePositionX, CornerstonePositionY, CornerstonePositionZ, CornerstoneRotationZ, " + "HousePositionX, HousePositionY, HousePositionZ, HouseRotationZ, Cost, VerifiedBuild) " + "VALUES ({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 0) " + "ON DUPLICATE KEY UPDATE DestinationX = VALUES(DestinationX), DestinationY = VALUES(DestinationY), " + "DestinationZ = VALUES(DestinationZ), DestinationO = VALUES(DestinationO), " + "CornerstoneEntry = VALUES(CornerstoneEntry), CornerstonePositionX = VALUES(CornerstonePositionX), " + "CornerstonePositionY = VALUES(CornerstonePositionY), CornerstonePositionZ = VALUES(CornerstonePositionZ), " + "CornerstoneRotationZ = VALUES(CornerstoneRotationZ), HousePositionX = VALUES(HousePositionX), " + "HousePositionY = VALUES(HousePositionY), HousePositionZ = VALUES(HousePositionZ), " + "HouseRotationZ = VALUES(HouseRotationZ), Cost = VALUES(Cost)", + entry->PlotIndex, exteriorMapId, HouseInteriorMapId, + entry->TeleportPosition.X, entry->TeleportPosition.Y, entry->TeleportPosition.Z, entry->TeleportFacing, + entry->CornerstoneGameObjectID, entry->CornerstonePosition.X, entry->CornerstonePosition.Y, + entry->CornerstonePosition.Z, entry->CornerstoneRotation.Z, entry->HousePosition.X, + entry->HousePosition.Y, entry->HousePosition.Z, entry->HouseRotation.Z, entry->Cost); + ++count; + } + if (count) + CharacterDatabase.DirectCommitTransaction(transaction); + + return bool(CharacterDatabase.PQuery( + "SELECT 1 FROM housing_plot_template WHERE ExteriorMapID = {} LIMIT 1", exteriorMapId)); + } + std::vector Manager::LoadOwnedHouses(Player const* player) const { std::vector houses; @@ -250,6 +311,9 @@ namespace Housing if (LoadPlot(player, plotId, plot) != Result::Success) return Result::HouseNotFound; + if (reserve && !IsNearCornerstone(player, plotId)) + return Result::PermissionDenied; + QueryResult neighborhood = CharacterDatabase.PQuery( "SELECT 1 FROM housing_neighborhood WHERE NeighborhoodGuid = '{}' LIMIT 1", neighborhoodGuid.ToString()); @@ -342,11 +406,18 @@ namespace Housing return Result::HouseNotFound; } - return CommitReservedPlot(player, neighborhoodGuid, plotId, state, chargedCost, moved); + return CommitReservedPlotLocked(player, neighborhoodGuid, plotId, state, chargedCost, moved); } Result Manager::CommitReservedPlot(Player* player, ObjectGuid const& neighborhoodGuid, uint8 plotId, HouseState& state, uint64* chargedCost, bool* moved) const + { + std::scoped_lock lock(HousingPlotTransactionMutex); + return CommitReservedPlotLocked(player, neighborhoodGuid, plotId, state, chargedCost, moved); + } + + Result Manager::CommitReservedPlotLocked(Player* player, ObjectGuid const& neighborhoodGuid, uint8 plotId, + HouseState& state, uint64* chargedCost, bool* moved) const { state = {}; if (chargedCost) @@ -392,7 +463,8 @@ namespace Housing } uint64 const purchaseCost = isMove ? 0 : plot.Cost; - if (purchaseCost && player->GetMoney() < purchaseCost) + if (!IsNearCornerstone(player, plotId) || purchaseCost > uint64(std::numeric_limits::max()) || + player->GetMoney() < purchaseCost) return Result::PermissionDenied; // Money and ownership must cross the durability boundary together. Debiting diff --git a/src/server/game/Housing/HousingMgr.h b/src/server/game/Housing/HousingMgr.h index 4bcfc5d9c..b69306ae0 100644 --- a/src/server/game/Housing/HousingMgr.h +++ b/src/server/game/Housing/HousingMgr.h @@ -21,6 +21,7 @@ namespace Housing uint32 GetExteriorMapId(Player const* player) const; bool IsPlayerOnHousingExterior(Player const* player, uint32* exteriorMapId = nullptr) const; + bool InitializePlotTemplates(uint32 exteriorMapId) const; Result LoadOwnedHouse(Player const* player, ObjectGuid const& requestedHouse, HouseState& state) const; Result LoadHouse(ObjectGuid const& houseGuid, HouseState& state, ObjectGuid* ownerGuid = nullptr) const; @@ -39,6 +40,8 @@ namespace Housing Result RelinquishOwnedHouse(Player const* player, ObjectGuid const& houseGuid, HouseState* previousState = nullptr) const; private: + Result CommitReservedPlotLocked(Player* player, ObjectGuid const& neighborhoodGuid, uint8 plotId, + HouseState& state, uint64* chargedCost, bool* moved) const; Manager() = default; }; } diff --git a/src/server/game/Housing/HousingSessionLifecycle.cpp b/src/server/game/Housing/HousingSessionLifecycle.cpp index 2443eff29..9adc41a33 100644 --- a/src/server/game/Housing/HousingSessionLifecycle.cpp +++ b/src/server/game/Housing/HousingSessionLifecycle.cpp @@ -502,11 +502,7 @@ namespace Housing response.Result = ToPacketResult(Result::HouseNotFound); session->SendPacket(response.Write()); - // Some retail clients display the tutorial button but reject it before - // sending CMSG_HOUSING_SVCS_START_TUTORIAL. The status request is the - // last verified dashboard interaction that reaches the server, so use - // it as a one-shot compatibility trigger for a new tutorial character. - TryStartTutorialFromDashboard(session); + // Status reads never start the tutorial. Use its opcode or the steward. } void HandlePlayerViewHousesByPlayer(WorldSession* session, WorldPackets::Housing::PlayerViewHousesByPlayer& packet) @@ -926,11 +922,11 @@ namespace Housing uint32 const guildId = neighborhoodFields[2].GetUInt32(); bool const isResident = response.OwnerGUID == player->GetGUID() || (guildId != 0 && guildId == player->GetGuildId()) || CharacterDatabase.PQuery( - "SELECT 1 FROM character_housing WHERE NeighborhoodGuid = '{}' AND OwnerGuid = {} LIMIT 1", - packet.NeighborhoodGUID.ToString(), player->GetGUID().GetCounter()) || CharacterDatabase.PQuery( - "SELECT 1 FROM character_housing_neighborhood_role WHERE NeighborhoodGuid = '{}' " - "AND CharacterGuid = {} AND Role = 1 LIMIT 1", - packet.NeighborhoodGUID.ToString(), player->GetGUID().GetCounter()); + "SELECT 1 FROM character_housing WHERE NeighborhoodGuid = '{}' AND OwnerGuid = {} LIMIT 1", + packet.NeighborhoodGUID.ToString(), player->GetGUID().GetCounter()) || CharacterDatabase.PQuery( + "SELECT 1 FROM character_housing_neighborhood_role WHERE NeighborhoodGuid = '{}' " + "AND CharacterGuid = {} AND Role = 1 LIMIT 1", + packet.NeighborhoodGUID.ToString(), player->GetGUID().GetCounter()); if (!response.OwnerGUID.IsPlayer() || !isResident) { response.Result = ToPacketResult(Result::PermissionDenied); @@ -1204,6 +1200,7 @@ namespace Housing if (result == Result::Success) { response.House = ToPacketHouse(state); + CreditTutorialHouseOwnership(player); if (moved && hadHouse) { SynchronizeExteriorMove(player, previousState, state); diff --git a/src/server/game/Housing/HousingTutorialSession.cpp b/src/server/game/Housing/HousingTutorialSession.cpp index 436978712..d2700adbb 100644 --- a/src/server/game/Housing/HousingTutorialSession.cpp +++ b/src/server/game/Housing/HousingTutorialSession.cpp @@ -1,230 +1,155 @@ /* * This file is part of the TrinityCore Project. See AUTHORS file for Copyright information */ - #include "HousingTutorialSession.h" #include "DatabaseEnv.h" -#include "HousingDefines.h" +#include "DB2Stores.h" #include "HousingMgr.h" #include "HousingMapContext.h" -#include "HousingPackets.h" -#include "HousingTutorialPackets.h" #include "Log.h" +#include "Map.h" +#include "MapManager.h" #include "ObjectMgr.h" #include "Player.h" -#include "SpellMgr.h" +#include "QuestDef.h" #include "WorldSession.h" +#include namespace Housing { namespace { - constexpr uint32 HousingTutorialQuestId = 91863; - constexpr uint32 HousingDecorTutorialQuestId = 91968; - constexpr uint32 HousingPlacementTutorialQuestId = 91969; - constexpr uint32 HousingTutorialAllianceSpellId = 1258476; - constexpr uint32 HousingTutorialHordeSpellId = 1258484; - ObjectGuid EnsureTutorialNeighborhood(Player const* player) { - if (!player) - return ObjectGuid::Empty; - HouseState house; if (sHousingMgr.LoadOwnedHouse(player, ObjectGuid::Empty, house) == Result::Success) return house.NeighborhoodGuid; - QueryResult existing = CharacterDatabase.PQuery( - "SELECT NeighborhoodGuid FROM housing_neighborhood WHERE OwnerGuid = {} " - "ORDER BY NeighborhoodGuid LIMIT 1", player->GetGUID().GetCounter()); - if (existing) - { - ObjectGuid const neighborhood = ObjectGuid::FromString(existing->Fetch()[0].GetString()); - if (IsNeighborhoodGuid(neighborhood)) - return neighborhood; - } - - uint32 const exteriorMapId = sHousingMgr.GetExteriorMapId(player); uint64 const owner = player->GetGUID().GetCounter(); + uint32 const mapId = sHousingMgr.GetExteriorMapId(player); ObjectGuid const neighborhood = ObjectGuid::Create( - 4, exteriorMapId, uint32(owner & 0xFFFFFFFFu), owner); + 4, mapId, uint32(owner & 0xFFFFFFFFu), owner); if (!IsNeighborhoodGuid(neighborhood)) return ObjectGuid::Empty; - char const* name = player->GetTeamId() == TEAM_HORDE - ? "Razorwind Shores" : "Founder''s Point"; + char const* name = player->GetTeamId() == TEAM_HORDE ? "Razorwind Shores" : "Founder''s Point"; CharacterDatabaseTransaction transaction = CharacterDatabase.BeginTransaction(); transaction->PAppend( - "INSERT INTO housing_neighborhood (NeighborhoodGuid, OwnerGuid, Name, Type, Flags) " - "VALUES ('{}', {}, '{}', 0, 0) ON DUPLICATE KEY UPDATE OwnerGuid = VALUES(OwnerGuid)", - neighborhood.ToString(), owner, name); + "INSERT IGNORE INTO housing_neighborhood (NeighborhoodGuid, OwnerGuid, Name, Type, Flags) " + "VALUES ('{}', {}, '{}', 0, 0)", neighborhood.ToString(), owner, name); transaction->PAppend( "INSERT INTO character_housing_map_context (OwnerGuid, MapID, ContextGuid) VALUES ({}, {}, '{}') " - "ON DUPLICATE KEY UPDATE ContextGuid = VALUES(ContextGuid)", - owner, exteriorMapId, neighborhood.ToString()); - // Never block the world-session thread waiting for the character database. - // The deterministic context GUID is valid immediately; both writes are - // idempotent and complete asynchronously before later persistence reads. - CharacterDatabase.CommitTransaction(transaction); + "ON DUPLICATE KEY UPDATE ContextGuid = VALUES(ContextGuid)", owner, mapId, neighborhood.ToString()); + // Map creation reads this context immediately; an asynchronous commit races it. + CharacterDatabase.DirectCommitTransaction(transaction); + if (!CharacterDatabase.PQuery( + "SELECT 1 FROM housing_neighborhood n INNER JOIN character_housing_map_context c " + "ON c.ContextGuid = n.NeighborhoodGuid WHERE n.NeighborhoodGuid = '{}' " + "AND n.OwnerGuid = {} AND c.OwnerGuid = {} AND c.MapID = {} LIMIT 1", + neighborhood.ToString(), owner, owner, mapId)) + { + TC_LOG_ERROR("housing", "Housing tutorial context could not be persisted for {}", player->GetGUID().ToString()); + return ObjectGuid::Empty; + } return neighborhood; } - - void ValidateTutorialData(Player const* player) - { - if (!sObjectMgr->GetQuestTemplate(HousingTutorialQuestId)) - TC_LOG_ERROR("housing", "Housing tutorial quest {} is missing from the world database", HousingTutorialQuestId); - - if (!sObjectMgr->GetQuestTemplate(HousingDecorTutorialQuestId)) - TC_LOG_ERROR("housing", "Housing tutorial quest {} is missing from the world database", HousingDecorTutorialQuestId); - - if (!sObjectMgr->GetQuestTemplate(HousingPlacementTutorialQuestId)) - TC_LOG_ERROR("housing", "Housing tutorial quest {} is missing from the world database", HousingPlacementTutorialQuestId); - - uint32 const transitionSpell = player && player->GetTeamId() == TEAM_HORDE - ? HousingTutorialHordeSpellId : HousingTutorialAllianceSpellId; - if (!sSpellMgr->GetSpellInfo(transitionSpell, DIFFICULTY_NONE)) - TC_LOG_ERROR("housing", "Housing tutorial transition spell {} is missing", transitionSpell); - } - - bool TeleportToTutorialExterior(Player* player, ObjectGuid const& neighborhood) - { - if (!player) - return false; - - uint32 const exteriorMapId = sHousingMgr.GetExteriorMapId(player); - QueryResult plotResult = CharacterDatabase.PQuery( - "SELECT PlotID FROM housing_plot_template WHERE ExteriorMapID = {} " - "ORDER BY PlotID, VerifiedBuild DESC LIMIT 1", - exteriorMapId); - if (!plotResult) - { - TC_LOG_ERROR("housing", "Housing tutorial has no plot template for exterior map {}", exteriorMapId); - return false; - } - - uint8 const plotId = plotResult->Fetch()[0].GetUInt8(); - PlotState plot; - if (sHousingMgr.LoadPlot(player, plotId, plot) != Result::Success) - { - TC_LOG_ERROR("housing", "Housing tutorial plot {} could not be loaded for player {}", - plotId, player->GetGUID().ToString()); - return false; - } - - return TeleportToMapContext(player, plot.ExteriorMapId, neighborhood, - plot.DestinationX, plot.DestinationY, plot.DestinationZ, plot.DestinationO); - } } - static void StartTutorial(WorldSession* session, bool allowDirectTeleport) + bool StartTutorial(WorldSession* session) { - if (!session) - return; + Player* player = session ? session->GetPlayer() : nullptr; + if (!player || !player->IsInWorld() || player->IsBeingTeleported()) + return false; - Player* player = session->GetPlayer(); - if (!player || !player->IsInWorld()) - return; + auto unavailable = [session]() + { + session->SendNotification("The housing tutorial is currently unavailable."); + return false; + }; - ValidateTutorialData(player); + Quest const* quest = sObjectMgr->GetQuestTemplate(TutorialQuestId); + if (!quest) + { + TC_LOG_ERROR("housing", "Housing tutorial quest {} is missing from world data", TutorialQuestId); + return unavailable(); + } + if (player->GetQuestStatus(TutorialQuestId) == QUEST_STATUS_NONE && + (!player->CanTakeQuest(quest, true) || !player->CanAddQuest(quest, true))) + return unavailable(); + + uint32 const mapId = sHousingMgr.GetExteriorMapId(player); + NeighborhoodMapEntry const* destination = nullptr; + for (NeighborhoodMapEntry const* entry : sNeighborhoodMapStore) + if (entry->MapID == int32(mapId)) + { + destination = entry; + break; + } + if (!destination || !MapManager::IsValidMapCoord(mapId, + destination->Position.X, destination->Position.Y, destination->Position.Z, destination->EntryRotation) || + !sHousingMgr.InitializePlotTemplates(mapId)) + { + TC_LOG_ERROR("housing", "Cannot start tutorial: valid NeighborhoodMap/NeighborhoodPlot data missing for map {}", mapId); + return unavailable(); + } ObjectGuid const neighborhood = EnsureTutorialNeighborhood(player); if (neighborhood.IsEmpty()) - { - TC_LOG_ERROR("housing", "Cannot start Housing tutorial for player {}: neighborhood context creation failed", - player->GetGUID().ToString()); - return; - } + return unavailable(); + if (!sHousingMgr.IsPlayerOnHousingExterior(player) || player->GetMap()->GetHousingContextGuid() != neighborhood) + if (!TeleportToMapContext(player, mapId, neighborhood, destination->Position.X, + destination->Position.Y, destination->Position.Z, destination->EntryRotation)) + return unavailable(); - // Explicitly clear any stale dashboard house context before starting the - // no-house tutorial flow. This is a protocol state update, not the final - // tutorial operation result. - WorldPackets::Housing::HouseStatusResponse houseStatus; - houseStatus.Result = uint8(Result::HouseNotFound); - session->SendPacket(houseStatus.Write()); - - if (Quest const* quest = sObjectMgr->GetQuestTemplate(HousingTutorialQuestId)) - { - if (player->GetQuestStatus(HousingTutorialQuestId) == QUEST_STATUS_NONE && player->CanAddQuest(quest, true)) - { - player->AddQuestAndCheckCompletion(quest, nullptr); - TC_LOG_INFO("housing", "Started Housing tutorial quest {} for player {}", - HousingTutorialQuestId, player->GetGUID().ToString()); - } - } - - // Prefer the retail transition. Creating/loading a HousingMap directly from - // HouseStatus blocks the world thread when the client-data map resources are - // missing or incomplete (observed for map 2953), which trips FreezeDetector. - uint32 const spellId = player->GetTeamId() == TEAM_HORDE - ? HousingTutorialHordeSpellId - : HousingTutorialAllianceSpellId; - if (sSpellMgr->GetSpellInfo(spellId, DIFFICULTY_NONE)) - { - TC_LOG_INFO("housing", "Starting retail Housing tutorial spell {} for player {} (exterior map {})", - spellId, player->GetGUID().ToString(), sHousingMgr.GetExteriorMapId(player)); - player->CastSpell(player, spellId, false); - return; - } - - if (!allowDirectTeleport) - { - TC_LOG_ERROR("housing", - "Cannot transition Housing tutorial for player {}: retail spell {} is missing; " - "direct dashboard teleport was suppressed to protect the world thread", - player->GetGUID().ToString(), spellId); - return; - } - - // Only an explicit tutorial opcode may use the compatibility teleport. - // It is never invoked from the automatic HouseStatus fallback. - if (TeleportToTutorialExterior(player, neighborhood)) - { - TC_LOG_INFO("housing", "Started Housing tutorial for player {} on exterior map {}", - player->GetGUID().ToString(), sHousingMgr.GetExteriorMapId(player)); - return; - } - - TC_LOG_ERROR("housing", "Cannot start Housing tutorial for player {}: transition spell {} " - "is missing and the direct exterior teleport failed", - player->GetGUID().ToString(), spellId); - } - - bool TryStartTutorialFromDashboard(WorldSession* session) - { - Player* player = session ? session->GetPlayer() : nullptr; - if (!player || !player->IsInWorld() || sHousingMgr.IsPlayerOnHousingExterior(player)) - return false; - - // HouseStatus runs on the world thread. Never create a HousingMap, cast a - // transition spell, or perform synchronous character-database reads here: - // both observed transition paths can block map/vmap loading until the - // FreezeDetector terminates the server. - if (player->GetQuestStatus(HousingTutorialQuestId) != QUEST_STATUS_NONE) - return false; - - Quest const* quest = sObjectMgr->GetQuestTemplate(HousingTutorialQuestId); - if (!quest || !player->CanAddQuest(quest, true)) - { - TC_LOG_ERROR("housing", - "Cannot arm Housing tutorial fallback for player {}: quest {} is missing or cannot be added", - player->GetGUID().ToString(), HousingTutorialQuestId); - return false; - } - - player->AddQuestAndCheckCompletion(quest, nullptr); - TC_LOG_INFO("housing", - "Armed Housing tutorial quest {} from dashboard for player {}; map transition suppressed", - HousingTutorialQuestId, player->GetGUID().ToString()); + if (player->GetQuestStatus(TutorialQuestId) == QUEST_STATUS_NONE) + player->AddQuestAndCheckCompletion(quest, nullptr); + TC_LOG_INFO("housing", "Started housing tutorial for {} in neighborhood {}", + player->GetGUID().ToString(), neighborhood.ToString()); return true; } - void HandleStartTutorial(WorldSession* session, - WorldPackets::Housing::HousingSvcsStartTutorial& /*packet*/) + void CreditTutorialObjective(Player* player, uint32 credit, ObjectGuid const& source) { - if (Player* player = session ? session->GetPlayer() : nullptr) - TC_LOG_INFO("housing", "Received Housing Start Tutorial request for player {} on map {}", - player->GetGUID().ToString(), player->GetMapId()); + if (!sHousingMgr.IsPlayerOnHousingExterior(player) || player->GetQuestStatus(TutorialQuestId) != QUEST_STATUS_INCOMPLETE) + return; + std::function const filter = [](QuestObjective const* objective) + { + return objective->QuestID == TutorialQuestId; + }; + player->UpdateQuestObjectiveProgress(QUEST_OBJECTIVE_MONSTER, int32(credit), 1, source, nullptr, &filter); + } - StartTutorial(session, true); + void CreditTutorialHouseOwnership(Player* player) + { + if (!sHousingMgr.IsPlayerOnHousingExterior(player) || player->GetQuestStatus(TutorialQuestId) != QUEST_STATUS_INCOMPLETE) + return; + HouseState house; + if (sHousingMgr.LoadOwnedHouse(player, ObjectGuid::Empty, house) != Result::Success || + house.NeighborhoodGuid != player->GetMap()->GetHousingContextGuid()) + return; + Quest const* quest = sObjectMgr->GetQuestTemplate(TutorialQuestId); + if (!quest) + return; + // Reconcile only ownership objectives from the durable house row, including + // a disconnect after purchase but before the quest progress was saved. + for (QuestObjective const& objective : quest->GetObjectives()) + { + if (objective.Type != QUEST_OBJECTIVE_MONSTER || objective.ObjectID != int32(TutorialAcquireHomeCredit)) + continue; + int32 const remaining = objective.Amount - player->GetQuestObjectiveData(objective); + if (remaining <= 0) + continue; + std::function const filter = [&objective](QuestObjective const* candidate) + { + return candidate->QuestID == TutorialQuestId && candidate->ID == objective.ID; + }; + player->UpdateQuestObjectiveProgress(QUEST_OBJECTIVE_MONSTER, int32(TutorialAcquireHomeCredit), + remaining, house.HouseGuid, nullptr, &filter); + } + } + + void HandleStartTutorial(WorldSession* session, WorldPackets::Housing::HousingSvcsStartTutorial& /*packet*/) + { + StartTutorial(session); } } diff --git a/src/server/game/Housing/HousingTutorialSession.h b/src/server/game/Housing/HousingTutorialSession.h index 9205b6a15..c3c332020 100644 --- a/src/server/game/Housing/HousingTutorialSession.h +++ b/src/server/game/Housing/HousingTutorialSession.h @@ -1,28 +1,24 @@ /* * This file is part of the TrinityCore Project. See AUTHORS file for Copyright information */ - #ifndef TRINITYCORE_HOUSING_TUTORIAL_SESSION_H #define TRINITYCORE_HOUSING_TUTORIAL_SESSION_H - #include "Define.h" - +#include "ObjectGuid.h" +class Player; class WorldSession; - -namespace WorldPackets::Housing -{ - class HousingSvcsStartTutorial; -} - +namespace WorldPackets::Housing { class HousingSvcsStartTutorial; } namespace Housing { - // Compatibility path for clients that render the tutorial button but block - // CMSG_HOUSING_SVCS_START_TUTORIAL locally. Returns true when the tutorial - // quest was armed from the dashboard's no-house status request. - TC_GAME_API bool TryStartTutorialFromDashboard(WorldSession* session); - - TC_GAME_API void HandleStartTutorial(WorldSession* session, - WorldPackets::Housing::HousingSvcsStartTutorial& packet); + constexpr uint32 TutorialQuestId = 91863; + constexpr uint32 TutorialGreetStewardCredit = 249851; + constexpr uint32 TutorialJoinCredit = 248857; + constexpr uint32 TutorialExplorePlotsCredit = 248858; + constexpr uint32 TutorialAcquireHomeCredit = 249093; + // Explicit actions only: status queries must not accept quests or teleport. + TC_GAME_API bool StartTutorial(WorldSession* session); + TC_GAME_API void CreditTutorialObjective(Player* player, uint32 credit, ObjectGuid const& source); + TC_GAME_API void CreditTutorialHouseOwnership(Player* player); + TC_GAME_API void HandleStartTutorial(WorldSession* session, WorldPackets::Housing::HousingSvcsStartTutorial& packet); } - #endif // TRINITYCORE_HOUSING_TUTORIAL_SESSION_H diff --git a/src/server/game/Maps/Map.cpp b/src/server/game/Maps/Map.cpp index 425f7bcd4..2a2af7255 100644 --- a/src/server/game/Maps/Map.cpp +++ b/src/server/game/Maps/Map.cpp @@ -4377,8 +4377,10 @@ void Map::SpawnHousingPlotCornerstones() QueryResult result = CharacterDatabase.PQuery( "SELECT PlotID, CornerstoneEntry, CornerstonePositionX, CornerstonePositionY, " - "CornerstonePositionZ, CornerstoneRotationZ FROM housing_plot_template " - "WHERE ExteriorMapID = {} ORDER BY PlotID", GetId()); + "CornerstonePositionZ, CornerstoneRotationZ FROM housing_plot_template p " + "WHERE ExteriorMapID = {} AND VerifiedBuild = (SELECT MAX(v.VerifiedBuild) " + "FROM housing_plot_template v WHERE v.ExteriorMapID = p.ExteriorMapID AND v.PlotID = p.PlotID) " + "ORDER BY PlotID", GetId()); if (!result) { TC_LOG_ERROR("housing", "No housing plot templates found for map {}", GetId()); @@ -4498,13 +4500,16 @@ void Map::RefreshHousingPlotHouseDoor(uint8 plotId) "SELECT p.DoorEntry, p.HousePositionX, p.HousePositionY, p.HousePositionZ, p.HouseRotationZ " "FROM housing_plot_template p WHERE p.ExteriorMapID = {} AND p.PlotID = {} " "AND EXISTS (SELECT 1 FROM character_housing h WHERE h.PlotID = p.PlotID " - "AND h.ExteriorMapID = p.ExteriorMapID AND h.NeighborhoodGuid = '{}') LIMIT 1", + "AND h.ExteriorMapID = p.ExteriorMapID AND h.NeighborhoodGuid = '{}') " + "ORDER BY p.VerifiedBuild DESC LIMIT 1", GetId(), uint32(plotId), _housingContextGuid.ToString()); if (!result) return; Field* fields = result->Fetch(); uint32 const entry = fields[0].GetUInt32(); + if (!entry) + return; // Door data is optional for the quest/purchase milestone. GameObjectTemplate const* doorTemplate = entry ? sObjectMgr->GetGameObjectTemplate(entry) : nullptr; if (!doorTemplate) { diff --git a/src/server/scripts/World/npc_housing_steward.cpp b/src/server/scripts/World/npc_housing_steward.cpp index 81428a68f..3d4525426 100644 --- a/src/server/scripts/World/npc_housing_steward.cpp +++ b/src/server/scripts/World/npc_housing_steward.cpp @@ -14,159 +14,70 @@ * You should have received a copy of the GNU General Public License along * with this program. If not, see . */ - #include "ScriptMgr.h" #include "CreatureAI.h" #include "GossipDef.h" #include "HousingMgr.h" #include "HousingTutorialSession.h" -#include "Log.h" -#include "ObjectMgr.h" #include "Player.h" #include "ScriptedGossip.h" -#include "SpellMgr.h" -enum HousingTutorialData +namespace { - // Quest IDs - QUEST_MY_FIRST_HOME = 91863, + constexpr uint32 StartTutorialAction = 1000; + constexpr uint32 JoinNeighborhoodAction = 1001; + bool IsFactionSteward(Player const* player, Creature const* creature) + { + return creature->GetEntry() == (player->GetTeamId() == TEAM_HORDE ? 233708u : 233063u); + } +} - // Quest: "My First Home" (91863) kill credit NPCs - // These IDs correspond to quest_objectives.ObjectID entries for quest 91863. - // Each KilledMonsterCredit() call increments the counter for that objective. - NPC_KILL_CREDIT_GREET_STEWARD = 249851, // objective: greet the steward (Amount 1) - NPC_KILL_CREDIT_ASK_STEWARD = 248857, // objective: ask steward to join (Amount 1) - NPC_KILL_CREDIT_EXPLORE_PLOTS = 248858, // objective: explore available plots (Amount 1) - NPC_KILL_CREDIT_VISIT_LOCATIONS = 248860, // objective: visit neighborhood locations (Amount 6) - NPC_KILL_CREDIT_ACQUIRE_HOME = 249093, // objectives: acquire a home (Amount 2+1 = 3 total) - - // Tutorial transition spells (retail fallback) - HOUSING_TUTORIAL_ALLIANCE_SPELL = 1258476, - HOUSING_TUTORIAL_HORDE_SPELL = 1258484, - -}; - -// Lyssabel Dawnpetal (233063) / Tocho (233708) - Housing tutorial steward NPCs. -// When the player interacts with the steward during the "My First Home" quest (91863), -// the gossip grants quest kill credits for greeting the steward and asking them to join. -// If the player has not yet started the tutorial, the NPC starts it for them. struct npc_housing_steward : public CreatureAI { - npc_housing_steward(Creature* creature) : CreatureAI(creature) {} - + using CreatureAI::CreatureAI; void UpdateAI(uint32 /*diff*/) override {} - bool StartTutorialForPlayer(Player* player) - { - Quest const* quest = sObjectMgr->GetQuestTemplate(QUEST_MY_FIRST_HOME); - if (!quest) - { - TC_LOG_ERROR("housing", "npc_housing_steward: Housing tutorial quest {} missing from world database", - QUEST_MY_FIRST_HOME); - return false; - } - - if (player->GetQuestStatus(QUEST_MY_FIRST_HOME) == QUEST_STATUS_NONE - && player->CanAddQuest(quest, true)) - { - player->AddQuestAndCheckCompletion(quest, nullptr); - TC_LOG_INFO("housing", "npc_housing_steward: Started Housing tutorial quest {} for player {}", - QUEST_MY_FIRST_HOME, player->GetGUID().ToString()); - } - - uint32 const exteriorMapId = sHousingMgr.GetExteriorMapId(player); - - QueryResult plotResult = CharacterDatabase.PQuery( - "SELECT PlotID FROM housing_plot_template WHERE ExteriorMapID = {} " - "ORDER BY PlotID, VerifiedBuild DESC LIMIT 1", - exteriorMapId); - if (plotResult) - { - uint8 const plotId = plotResult->Fetch()[0].GetUInt8(); - Housing::PlotState plot; - if (sHousingMgr.LoadPlot(player, plotId, plot) == Housing::Result::Success) - { - if (player->TeleportTo(plot.ExteriorMapId, plot.DestinationX, plot.DestinationY, - plot.DestinationZ, plot.DestinationO)) - { - TC_LOG_INFO("housing", "npc_housing_steward: Teleported player {} to tutorial plot on map {}", - player->GetGUID().ToString(), exteriorMapId); - return true; - } - } - } - - uint32 const spellId = player->GetTeamId() == TEAM_HORDE - ? HOUSING_TUTORIAL_HORDE_SPELL - : HOUSING_TUTORIAL_ALLIANCE_SPELL; - if (sSpellMgr->GetSpellInfo(spellId, DIFFICULTY_NONE)) - { - TC_LOG_WARN("housing", "npc_housing_steward: Plot destination unavailable; casting retail spell {} for player {}", - spellId, player->GetGUID().ToString()); - player->CastSpell(player, spellId, false); - return true; - } - - TC_LOG_ERROR("housing", "npc_housing_steward: No tutorial transition available for player {}: " - "no plot data and spell {} missing", player->GetGUID().ToString(), spellId); - return false; - } - bool OnGossipHello(Player* player) override { - // If the player has not started the Housing tutorial yet, start it now. - // This covers the case where the client never sends CMSG_HOUSING_SVCS_START_TUTORIAL. - if (player->GetQuestStatus(QUEST_MY_FIRST_HOME) == QUEST_STATUS_NONE) + bool const inNeighborhood = sHousingMgr.IsPlayerOnHousingExterior(player); + if (inNeighborhood && !IsFactionSteward(player, me)) + return false; + + if (inNeighborhood) { - CloseGossipMenuFor(player); - StartTutorialForPlayer(player); - return true; + Housing::CreditTutorialObjective(player, Housing::TutorialGreetStewardCredit, me->GetGUID()); + player->TalkedToCreature(me->GetEntry(), me->GetGUID()); + Housing::CreditTutorialHouseOwnership(player); + if (player->GetQuestStatus(Housing::TutorialQuestId) != QUEST_STATUS_INCOMPLETE) + return false; // preserve normal quest acceptance and turn-in } - // Grant "Greet the steward" kill credit (quest objective: MONSTER 249851) - player->KilledMonsterCredit(NPC_KILL_CREDIT_GREET_STEWARD); + InitGossipMenuFor(player, 0); + if (me->IsQuestGiver()) + player->PrepareQuestMenu(me->GetGUID()); + if (inNeighborhood) + AddGossipItemFor(player, GossipOptionNpc::None, "Ask the steward to become your neighbor.", + GOSSIP_SENDER_MAIN, JoinNeighborhoodAction); + else + AddGossipItemFor(player, GossipOptionNpc::None, "Start the housing tutorial.", + GOSSIP_SENDER_MAIN, StartTutorialAction); + SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); + return true; + } - // Satisfy "Talk to Lyssabel/Tocho" objective (quest objective: TALKTO with NPC entry) - player->TalkedToCreature(me->GetEntry(), me->GetGUID()); - - // Grant all remaining kill credits for quest 91863 objectives that cannot be - // completed through normal gameplay in the repack (visit locations, explore plots, - // acquire home). This ensures the quest reaches QUEST_STATUS_COMPLETE so the - // player can turn it in via the normal quest reward flow. - if (player->GetQuestStatus(QUEST_MY_FIRST_HOME) == QUEST_STATUS_INCOMPLETE) + bool OnGossipSelect(Player* player, uint32 /*menuId*/, uint32 gossipListId) override + { + uint32 const action = GetGossipActionFor(player, gossipListId); + CloseGossipMenuFor(player); + if (action == StartTutorialAction && !sHousingMgr.IsPlayerOnHousingExterior(player)) + Housing::StartTutorial(player->GetSession()); + else if (action == JoinNeighborhoodAction && sHousingMgr.IsPlayerOnHousingExterior(player) && + IsFactionSteward(player, me)) { - // Objective: ask steward to join (Amount 1) - player->KilledMonsterCredit(NPC_KILL_CREDIT_ASK_STEWARD); - - // Objective: explore available plots (Amount 1) - player->KilledMonsterCredit(NPC_KILL_CREDIT_EXPLORE_PLOTS); - - // Objective: visit neighborhood locations (Amount 6 - grant all at once) - for (uint32 i = 0; i < 6; ++i) - player->KilledMonsterCredit(NPC_KILL_CREDIT_VISIT_LOCATIONS); - - // Objective: acquire a home (Amount 3 total across two objectives) - for (uint32 i = 0; i < 3; ++i) - player->KilledMonsterCredit(NPC_KILL_CREDIT_ACQUIRE_HOME); - - // Objective: talk to the other faction's steward (233708 for Alliance / 233063 for Horde) - // The player can't visit the other faction's map, so grant both talkto credits. - if (player->GetTeamId() == TEAM_ALLIANCE) - player->TalkedToCreature(233708, ObjectGuid::Empty); - else - player->TalkedToCreature(233063, ObjectGuid::Empty); - - TC_LOG_INFO("housing", "npc_housing_steward: Player {} granted all remaining quest {} credits via steward {}", - player->GetGUID().ToString(), QUEST_MY_FIRST_HOME, me->GetEntry()); + Housing::CreditTutorialObjective(player, Housing::TutorialJoinCredit, me->GetGUID()); + Housing::CreditTutorialHouseOwnership(player); } - - TC_LOG_DEBUG("housing", "npc_housing_steward: Player {} greeted steward {} (kill credit {}, talkto {})", - player->GetGUID().ToString(), me->GetEntry(), NPC_KILL_CREDIT_GREET_STEWARD, me->GetEntry()); - - // If the quest is now complete (or was already complete), return false to let the - // default QuestGiver pathway handle the turn-in (request items / offer reward). - // For any other quest status, also return false for default gossip handling. - return false; + return true; } };