From 5def5c28d0fc49e25ac8856c54b1db806eec4c8f Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 10 Mar 2026 13:36:47 -0300 Subject: [PATCH] Housing: Wire up initiative/endeavor system and fix interior editor unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register PlayerInitiativeComponent_C entity fragment (FragmentID 37) on player load so C_NeighborhoodInitiative Lua API returns initiative state - Populate InitiativeInfo fields (duration, progress, milestone, cycle, contribution) and Houses set from InitiativeManager data - Send SMSG_INITIATIVE_SERVICE_STATUS (0x80=enabled) proactively on both exterior and interior map entry so IsInitiativeEnabled() returns true immediately - Set IsInitiative flag on cornerstone UI response when neighborhood has active initiative - Add InsertSetUpdateFieldValue friend declaration to SetUpdateFieldSetter (was missing, preventing set-type update field insertion from compiling) - Send SMSG_HOUSING_GET_CURRENT_HOUSE_INFO_RESPONSE in interior map so editor UI has proper house context - Add SendPostTutorialAuras to HouseInteriorMap to unlock all editor modes (expert, cleanup, layout, customize) — auras are lost on map transfer and must be re-sent --- .../Entities/Object/Updates/UpdateField.h | 3 + src/server/game/Entities/Player/Player.cpp | 157 +++++++++++++- src/server/game/Entities/Player/Player.h | 2 + .../game/Handlers/NeighborhoodHandler.cpp | 4 + src/server/game/Housing/HouseInteriorMap.cpp | 198 ++++++++++++++++++ src/server/game/Housing/HouseInteriorMap.h | 6 +- src/server/game/Housing/HousingMap.cpp | 9 + 7 files changed, 376 insertions(+), 3 deletions(-) diff --git a/src/server/game/Entities/Object/Updates/UpdateField.h b/src/server/game/Entities/Object/Updates/UpdateField.h index c06d6de14..9b2af14df 100644 --- a/src/server/game/Entities/Object/Updates/UpdateField.h +++ b/src/server/game/Entities/Object/Updates/UpdateField.h @@ -288,6 +288,9 @@ namespace UF template struct SetUpdateFieldSetter { + template + friend bool InsertSetUpdateFieldValue(SetUpdateFieldSetter& setter, std::type_identity_t const& key); + template friend bool RemoveSetUpdateFieldValue(SetUpdateFieldSetter& setter, std::type_identity_t const& key); diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 77ab0788e..fbe036f96 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -3790,6 +3790,8 @@ void Player::ClearValuesChangesMask() { m_values.ClearChangesMask(&Player::m_playerData); m_values.ClearChangesMask(&Player::m_activePlayerData); + m_values.ClearChangesMask(&Player::m_playerHouseInfoComponentData); + m_values.ClearChangesMask(&Player::m_playerInitiativeComponentData); Unit::ClearValuesChangesMask(); } @@ -18849,9 +18851,14 @@ bool Player::LoadFromDB(ObjectGuid guid, CharacterDatabaseQueryHolder const& hol holder.GetPreparedResult(PLAYER_LOGIN_QUERY_LOAD_HOUSING_CATALOG))) _housings.push_back(std::move(housing)); + // TODO: When the housing tutorial questline is implemented, this brute-force approach + // (setting all tutorial bits + injecting closedInfoFramesAccountWide / housingTutorialsEnabled + // CVars) must be replaced with proper quest-driven tutorial progression. The client Lua UI + // sets FrameTutorialAccount bits (e.g. HousingModesUnlocked=38) individually as the player + // completes each tutorial step. At that point, remove the blanket CVar injection below and + // let the questline handlers set the appropriate closedInfoFramesAccountWide bits on completion. + // // Mark all tutorials as seen. Retail sniff shows all 256 tutorial bits set to 1 (0xFF bytes). - // Without this, the client blocks housing cleanup/expert modes with "Mode not available - // while in the tutorial" (FrameTutorialAccount.HousingModesUnlocked = bit 38). if (GetSession()) { bool needsUpdate = false; @@ -18865,6 +18872,62 @@ bool Player::LoadFromDB(ObjectGuid guid, CharacterDatabaseQueryHolder const& hol } if (needsUpdate) GetSession()->SendTutorialsData(); + + // The 256-bit server tutorial flags (above) are separate from the client's + // FrameTutorialAccount UI flags. The client stores those in the CVar bitfield + // "closedInfoFramesAccountWide" within the GLOBAL_CONFIG_CACHE account data. + // Without setting bit 38 (HousingModesUnlocked), the housing editor UI keeps + // expert/cleanup/layout modes locked with "Tutorial Mode" error. + // Also disable housing tutorials entirely via housingTutorialsEnabled=0. + AccountData const* configCache = GetSession()->GetAccountData(GLOBAL_CONFIG_CACHE); + std::string configData = configCache ? configCache->Data : ""; + bool configModified = false; + + // Helper lambda: set or replace a CVar value in the config string + auto ensureCVar = [&](std::string_view cvarName, std::string_view value) + { + std::string setPrefix = std::string("SET ") + std::string(cvarName) + " \""; + size_t pos = configData.find(setPrefix); + if (pos != std::string::npos) + { + // Replace existing value + size_t valStart = pos + setPrefix.size(); + size_t valEnd = configData.find('"', valStart); + if (valEnd != std::string::npos) + { + std::string oldVal = configData.substr(valStart, valEnd - valStart); + if (oldVal != value) + { + configData.replace(valStart, valEnd - valStart, value); + configModified = true; + } + } + } + else + { + // Append new CVar + if (!configData.empty() && configData.back() != '\n') + configData += '\n'; + configData += "SET "; + configData += cvarName; + configData += " \""; + configData += value; + configData += "\"\n"; + configModified = true; + } + }; + + // Mark ALL FrameTutorialAccount bits as seen (48 bits = 2 uint32 words, all 1s) + ensureCVar("closedInfoFramesAccountWide", "4294967295 4294967295"); + // Disable housing tutorial gates entirely + ensureCVar("housingTutorialsEnabled", "0"); + + if (configModified) + { + GetSession()->SetAccountData(GLOBAL_CONFIG_CACHE, GameTime::GetGameTime(), configData); + TC_LOG_DEBUG("housing", "Player::LoadFromDB: Injected housing tutorial CVars into GLOBAL_CONFIG_CACHE for account {}", + GetSession()->GetAccountId()); + } } // Always register PlayerHouseInfoComponent_C fragment on the Player entity. @@ -18915,6 +18978,96 @@ bool Player::LoadFromDB(ObjectGuid guid, CharacterDatabaseQueryHolder const& hol mirrorHouse.InitiativeFavor = sInitiativeManager.GetPlayerContribution(nhGuid, activeInit->InitiativeID, GetGUID().GetCounter()); } } + // Register PlayerInitiativeComponent_C fragment (FragmentID 37) on the Player entity. + // The client's C_NeighborhoodInitiative Lua API reads initiative state from this fragment. + // Without it, GetNeighborhoodInitiativeInfo() returns nil and the initiative/endeavor UI + // never appears. Sniff-verified: all neighborhood players have this fragment. + if (!m_playerInitiativeComponentData.has_value()) + { + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::NeighborhoodGUID), ObjectGuid::Empty); + m_entityFragments.Add(WowCS::EntityFragment::PlayerInitiativeComponent_C, false, + WowCS::GetRawFragmentData(m_playerInitiativeComponentData)); + } + + // Populate initiative data for the player's neighborhood + if (!_housings.empty() && _housings[0] && !_housings[0]->GetNeighborhoodGuid().IsEmpty()) + { + ObjectGuid nhGuid = _housings[0]->GetNeighborhoodGuid(); + uint64 nhLowGuid = nhGuid.GetCounter(); + ActiveInitiative* activeInit = sInitiativeManager.GetActiveInitiative(nhLowGuid); + + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::NeighborhoodGUID), nhGuid); + + if (activeInit) + { + NeighborhoodInitiativeEntry const* initEntry = sNeighborhoodInitiativeStore.LookupEntry(activeInit->InitiativeID); + uint32 cycleID = sInitiativeManager.GetActiveCycleForInitiative(activeInit->InitiativeID); + + // Calculate remaining duration from start time + DB2 duration + int64 remainingDuration = 0; + if (initEntry && initEntry->Duration > 0) + { + int64 elapsed = static_cast(GameTime::GetGameTime()) - static_cast(activeInit->StartTime); + remainingDuration = std::max(0, static_cast(initEntry->Duration) - elapsed); + } + + // Calculate progress in the 0-1000 scale (sniff: ProgressRequired=1000) + float progressRequired = 1000.0f; + float currentProgress = activeInit->Progress * progressRequired; + + // Find current milestone + int32 currentMilestoneID = -1; + auto milestones = sInitiativeManager.GetMilestonesForCycle(cycleID); + for (auto const& m : milestones) + { + if (activeInit->Progress < m.ProgressRequired) + { + currentMilestoneID = static_cast(m.MilestoneID); + break; + } + } + + float playerContribution = static_cast( + sInitiativeManager.GetPlayerContribution(nhLowGuid, activeInit->InitiativeID, GetGUID().GetCounter())); + + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::RemainingDuration), remainingDuration); + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::CurrentInitiativeID), static_cast(activeInit->InitiativeID)); + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::CurrentMilestoneID), currentMilestoneID); + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::CurrentCycleID), static_cast(cycleID)); + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::ProgressRequired), progressRequired); + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::CurrentProgress), currentProgress); + SetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::InitiativeInfo) + .ModifyValue(&UF::PlayerInitiativeInfo::PlayerTotalContribution), playerContribution); + + // Add house GUIDs to the Houses set + for (auto const& h : _housings) + { + if (h && !h->GetHouseGuid().IsEmpty()) + InsertSetUpdateFieldValue(m_values.ModifyValue(&Player::m_playerInitiativeComponentData, 0) + .ModifyValue(&UF::PlayerInitiativeComponentData::Houses), h->GetHouseGuid()); + } + + TC_LOG_DEBUG("housing", "Player::LoadFromDB: Populated PlayerInitiativeComponentData: " + "InitiativeID={} CycleID={} Progress={:.1f}/{:.0f} Milestone={} Duration={}", + activeInit->InitiativeID, cycleID, currentProgress, progressRequired, + currentMilestoneID, remainingDuration); + } + } //WowCommunity diff --git a/src/server/game/Entities/Player/Player.h b/src/server/game/Entities/Player/Player.h index 75e6c3e47..fa96c775a 100644 --- a/src/server/game/Entities/Player/Player.h +++ b/src/server/game/Entities/Player/Player.h @@ -3047,6 +3047,8 @@ class TC_GAME_API Player final : public Unit, public GridObject // Housing entity fragment (optional - only set when player has housing data) UF::OptionalUpdateField m_playerHouseInfoComponentData; + // Initiative entity fragment (optional - initiative/endeavor state for UI) + UF::OptionalUpdateField m_playerInitiativeComponentData; void SetAreaSpiritHealer(Creature* creature); ObjectGuid const& GetSpiritHealerGUID() const { return _areaSpiritHealerGUID; } diff --git a/src/server/game/Handlers/NeighborhoodHandler.cpp b/src/server/game/Handlers/NeighborhoodHandler.cpp index d32bc437a..c316ac132 100644 --- a/src/server/game/Handlers/NeighborhoodHandler.cpp +++ b/src/server/game/Handlers/NeighborhoodHandler.cpp @@ -1566,6 +1566,10 @@ void WorldSession::HandleNeighborhoodOpenCornerstoneUI(WorldPackets::Neighborhoo response.CanPurchase = !isOwned; response.NeighborhoodName = neighborhood->GetName(); + // Set IsInitiative when the neighborhood has an active initiative/endeavor + uint64 nhLowGuid = neighborhood->GetGuid().GetCounter(); + response.IsInitiative = (sInitiativeManager.GetActiveInitiative(nhLowGuid) != nullptr); + if (isOwned) { // Owned plot: show owner info, no purchase available diff --git a/src/server/game/Housing/HouseInteriorMap.cpp b/src/server/game/Housing/HouseInteriorMap.cpp index 6ed010e31..3d3ca2d5e 100644 --- a/src/server/game/Housing/HouseInteriorMap.cpp +++ b/src/server/game/Housing/HouseInteriorMap.cpp @@ -30,6 +30,9 @@ #include "ObjectMgr.h" #include "PhasingHandler.h" #include "Player.h" +#include "Spell.h" +#include "SpellAuraDefines.h" +#include "SpellPackets.h" #include "World.h" #include "WorldSession.h" @@ -753,6 +756,27 @@ bool HouseInteriorMap::AddPlayerToMap(Player* player, bool initPlayer /*= true*/ } } + // Send SMSG_HOUSING_GET_CURRENT_HOUSE_INFO_RESPONSE so the client knows the + // active house context. Without this, the client has no house context and the + // editor UI (C_HouseEditor.GetHouseEditorModeAvailability) won't show. + // The exterior map (HousingMap::AddPlayerToMap) sends this ? the interior must too. + { + WorldPackets::Housing::HousingGetCurrentHouseInfoResponse houseInfo; + houseInfo.House.HouseGuid = housing->GetHouseGuid(); + houseInfo.House.OwnerGuid = player->GetGUID(); + houseInfo.House.NeighborhoodGuid = housing->GetNeighborhoodGuid(); + houseInfo.House.PlotId = housing->GetPlotIndex(); + houseInfo.House.AccessFlags = housing->GetSettingsFlags(); + houseInfo.House.HasMoveOutTime = false; + houseInfo.Result = 0; + player->SendDirectMessage(houseInfo.Write()); + + TC_LOG_DEBUG("housing", "HouseInteriorMap::AddPlayerToMap: Sent CURRENT_HOUSE_INFO " + "HouseGuid={} OwnerGuid={} NeighborhoodGuid={} PlotId={}", + housing->GetHouseGuid().ToString(), player->GetGUID().ToString(), + housing->GetNeighborhoodGuid().ToString(), housing->GetPlotIndex()); + } + // Send SMSG_HOUSE_INTERIOR_ENTER_HOUSE WorldPackets::Housing::HouseInteriorEnterHouse enterHouse; enterHouse.HouseGuid = housing->GetHouseGuid(); @@ -766,6 +790,20 @@ bool HouseInteriorMap::AddPlayerToMap(Player* player, bool initPlayer /*= true*/ statusResponse.NeighborhoodGuid = housing->GetNeighborhoodGuid(); statusResponse.Status = 1; // Interior player->SendDirectMessage(statusResponse.Write()); + + // Send post-tutorial auras so the client unlocks all editor modes. + // These auras signal "tutorial complete" and are lost on map transfer. + // The exterior (HousingMap) sends them via SendPostTutorialAuras ? + // the interior must do the same or the editor remains locked. + SendPostTutorialAuras(player); + + // Send SMSG_INITIATIVE_SERVICE_STATUS so IsInitiativeEnabled() returns true + // in interior. Sniff-verified: server responds with 0x80 (enabled). + { + WorldPackets::Housing::InitiativeServiceStatus initStatus; + initStatus.ServiceEnabled = true; + player->SendDirectMessage(initStatus.Write()); + } } else { @@ -798,3 +836,163 @@ void HouseInteriorMap::RemovePlayerFromMap(Player* player, bool remove) Map::RemovePlayerFromMap(player, remove); } + +void HouseInteriorMap::SendPostTutorialAuras(Player* player) +{ + // Sniff-verified: After quest 94455 "Home at Last" completion, three "post-tutorial" auras + // are applied at slots 8, 9, 50. These signal "tutorial complete" to the client and unlock + // all editor modes (expert, cleanup, layout, customize). Auras are lost on map transfer, + // so they must be re-sent when entering both the exterior AND interior housing maps. + // Slot 8: spell 1285428 (NoCaster, ActiveFlags=1) + // Slot 9: spell 1285424 (NoCaster, ActiveFlags=1) + // Slot 50: spell 1266699 (NoCaster|Scalable, ActiveFlags=1, Points=1) + // + // TODO: When the housing tutorial questline is implemented, these auras should only be + // sent for players who have actually completed the tutorial quest (94455 "Home at Last"). + + // Spell 1285428 at slot 8 + { + ObjectGuid castId = ObjectGuid::Create( + SPELL_CAST_SOURCE_NORMAL, player->GetMapId(), SPELL_HOUSING_TUTORIAL_DONE_1, + player->GetMap()->GenerateLowGuid()); + + WorldPackets::Spells::AuraUpdate auraUpdate; + auraUpdate.UpdateAll = false; + auraUpdate.UnitGUID = player->GetGUID(); + + WorldPackets::Spells::AuraInfo auraInfo; + auraInfo.Slot = 8; + auraInfo.AuraData.emplace(); + auraInfo.AuraData->CastID = castId; + auraInfo.AuraData->SpellID = SPELL_HOUSING_TUTORIAL_DONE_1; + auraInfo.AuraData->Flags = AFLAG_NOCASTER; + auraInfo.AuraData->ActiveFlags = 1; + auraInfo.AuraData->CastLevel = 36; + auraInfo.AuraData->Applications = 0; + auraUpdate.Auras.push_back(std::move(auraInfo)); + player->SendDirectMessage(auraUpdate.Write()); + + WorldPackets::Spells::SpellStart spellStart; + spellStart.Cast.CasterGUID = player->GetGUID(); + spellStart.Cast.CasterUnit = player->GetGUID(); + spellStart.Cast.CastID = castId; + spellStart.Cast.SpellID = SPELL_HOUSING_TUTORIAL_DONE_1; + spellStart.Cast.CastFlags = CAST_FLAG_PENDING | CAST_FLAG_HAS_TRAJECTORY | CAST_FLAG_UNKNOWN_3 | CAST_FLAG_UNKNOWN_4; // 15 + spellStart.Cast.CastTime = 0; + player->SendDirectMessage(spellStart.Write()); + + WorldPackets::Spells::SpellGo spellGo; + spellGo.Cast.CasterGUID = player->GetGUID(); + spellGo.Cast.CasterUnit = player->GetGUID(); + spellGo.Cast.CastID = castId; + spellGo.Cast.SpellID = SPELL_HOUSING_TUTORIAL_DONE_1; + spellGo.Cast.CastFlags = CAST_FLAG_PENDING | CAST_FLAG_UNKNOWN_3 | CAST_FLAG_UNKNOWN_4 | CAST_FLAG_UNKNOWN_9 | CAST_FLAG_UNKNOWN_10; // 781 + spellGo.Cast.CastFlagsEx = 16; + spellGo.Cast.CastFlagsEx2 = 4; + spellGo.Cast.CastTime = getMSTime(); + spellGo.Cast.Target.Flags = TARGET_FLAG_UNIT; + spellGo.Cast.HitTargets.push_back(player->GetGUID()); + spellGo.Cast.HitStatus.emplace_back(uint8(0)); + spellGo.LogData.Initialize(player); + player->SendDirectMessage(spellGo.Write()); + } + + // Spell 1285424 at slot 9 + { + ObjectGuid castId = ObjectGuid::Create( + SPELL_CAST_SOURCE_NORMAL, player->GetMapId(), SPELL_HOUSING_TUTORIAL_DONE_2, + player->GetMap()->GenerateLowGuid()); + + WorldPackets::Spells::AuraUpdate auraUpdate; + auraUpdate.UpdateAll = false; + auraUpdate.UnitGUID = player->GetGUID(); + + WorldPackets::Spells::AuraInfo auraInfo; + auraInfo.Slot = 9; + auraInfo.AuraData.emplace(); + auraInfo.AuraData->CastID = castId; + auraInfo.AuraData->SpellID = SPELL_HOUSING_TUTORIAL_DONE_2; + auraInfo.AuraData->Flags = AFLAG_NOCASTER; + auraInfo.AuraData->ActiveFlags = 1; + auraInfo.AuraData->CastLevel = 36; + auraInfo.AuraData->Applications = 0; + auraUpdate.Auras.push_back(std::move(auraInfo)); + player->SendDirectMessage(auraUpdate.Write()); + + WorldPackets::Spells::SpellStart spellStart; + spellStart.Cast.CasterGUID = player->GetGUID(); + spellStart.Cast.CasterUnit = player->GetGUID(); + spellStart.Cast.CastID = castId; + spellStart.Cast.SpellID = SPELL_HOUSING_TUTORIAL_DONE_2; + spellStart.Cast.CastFlags = CAST_FLAG_PENDING | CAST_FLAG_HAS_TRAJECTORY | CAST_FLAG_UNKNOWN_3 | CAST_FLAG_UNKNOWN_4; + spellStart.Cast.CastTime = 0; + player->SendDirectMessage(spellStart.Write()); + + WorldPackets::Spells::SpellGo spellGo; + spellGo.Cast.CasterGUID = player->GetGUID(); + spellGo.Cast.CasterUnit = player->GetGUID(); + spellGo.Cast.CastID = castId; + spellGo.Cast.SpellID = SPELL_HOUSING_TUTORIAL_DONE_2; + spellGo.Cast.CastFlags = CAST_FLAG_PENDING | CAST_FLAG_UNKNOWN_3 | CAST_FLAG_UNKNOWN_4 | CAST_FLAG_UNKNOWN_9 | CAST_FLAG_UNKNOWN_10; + spellGo.Cast.CastFlagsEx = 16; + spellGo.Cast.CastFlagsEx2 = 4; + spellGo.Cast.CastTime = getMSTime(); + spellGo.Cast.Target.Flags = TARGET_FLAG_UNIT; + spellGo.Cast.HitTargets.push_back(player->GetGUID()); + spellGo.Cast.HitStatus.emplace_back(uint8(0)); + spellGo.LogData.Initialize(player); + player->SendDirectMessage(spellGo.Write()); + } + + // Spell 1266699 at slot 50 (same ID as SPELL_HOUSING_PLOT_ENTER_2, different slot + Points) + { + ObjectGuid castId = ObjectGuid::Create( + SPELL_CAST_SOURCE_NORMAL, player->GetMapId(), SPELL_HOUSING_TUTORIAL_DONE_3, + player->GetMap()->GenerateLowGuid()); + + WorldPackets::Spells::AuraUpdate auraUpdate; + auraUpdate.UpdateAll = false; + auraUpdate.UnitGUID = player->GetGUID(); + + WorldPackets::Spells::AuraInfo auraInfo; + auraInfo.Slot = 50; + auraInfo.AuraData.emplace(); + auraInfo.AuraData->CastID = castId; + auraInfo.AuraData->SpellID = SPELL_HOUSING_TUTORIAL_DONE_3; + auraInfo.AuraData->Flags = AFLAG_NOCASTER | AFLAG_SCALABLE; + auraInfo.AuraData->ActiveFlags = 1; + auraInfo.AuraData->CastLevel = 36; + auraInfo.AuraData->Applications = 0; + auraInfo.AuraData->Points.push_back(1.0f); + auraUpdate.Auras.push_back(std::move(auraInfo)); + player->SendDirectMessage(auraUpdate.Write()); + + WorldPackets::Spells::SpellStart spellStart; + spellStart.Cast.CasterGUID = player->GetGUID(); + spellStart.Cast.CasterUnit = player->GetGUID(); + spellStart.Cast.CastID = castId; + spellStart.Cast.SpellID = SPELL_HOUSING_TUTORIAL_DONE_3; + spellStart.Cast.CastFlags = CAST_FLAG_PENDING | CAST_FLAG_HAS_TRAJECTORY | CAST_FLAG_UNKNOWN_3 | CAST_FLAG_UNKNOWN_4; + spellStart.Cast.CastTime = 0; + player->SendDirectMessage(spellStart.Write()); + + WorldPackets::Spells::SpellGo spellGo; + spellGo.Cast.CasterGUID = player->GetGUID(); + spellGo.Cast.CasterUnit = player->GetGUID(); + spellGo.Cast.CastID = castId; + spellGo.Cast.SpellID = SPELL_HOUSING_TUTORIAL_DONE_3; + spellGo.Cast.CastFlags = CAST_FLAG_PENDING | CAST_FLAG_UNKNOWN_3 | CAST_FLAG_UNKNOWN_4 | CAST_FLAG_UNKNOWN_9 | CAST_FLAG_UNKNOWN_10; + spellGo.Cast.CastFlagsEx = 16; + spellGo.Cast.CastFlagsEx2 = 4; + spellGo.Cast.CastTime = getMSTime(); + spellGo.Cast.Target.Flags = TARGET_FLAG_UNIT; + spellGo.Cast.HitTargets.push_back(player->GetGUID()); + spellGo.Cast.HitStatus.emplace_back(uint8(0)); + spellGo.LogData.Initialize(player); + player->SendDirectMessage(spellGo.Write()); + } + + TC_LOG_DEBUG("housing", "HouseInteriorMap::SendPostTutorialAuras: Sent 3 post-tutorial aura sequences " + "(1285428@s8, 1285424@s9, 1266699@s50) for player {}", + player->GetGUID().ToString()); +} diff --git a/src/server/game/Housing/HouseInteriorMap.h b/src/server/game/Housing/HouseInteriorMap.h index 9972dd382..ece0b9441 100644 --- a/src/server/game/Housing/HouseInteriorMap.h +++ b/src/server/game/Housing/HouseInteriorMap.h @@ -72,6 +72,10 @@ public: /// Despawn a single decor item by its Housing decor GUID. void DespawnDecorItem(ObjectGuid decorGuid); + /// Send post-tutorial aura packets so the client knows the tutorial is complete + /// and unlocks all editor modes (expert, cleanup, layout, customize). + void SendPostTutorialAuras(Player* player); + private: ObjectGuid _owner; Player* _loadingPlayer; ///< @workaround Player not in ObjectAccessor during login @@ -87,7 +91,7 @@ private: /// GUIDs of all spawned room MeshObjects, indexed by room GUID std::unordered_map> _roomMeshObjects; - /// Decor GUID → visual object GUID (for despawning individual decor items) + /// Decor GUID ? visual object GUID (for despawning individual decor items) std::unordered_map _decorGuidToObjGuid; }; diff --git a/src/server/game/Housing/HousingMap.cpp b/src/server/game/Housing/HousingMap.cpp index 609940de4..0327f33e7 100644 --- a/src/server/game/Housing/HousingMap.cpp +++ b/src/server/game/Housing/HousingMap.cpp @@ -781,6 +781,15 @@ bool HousingMap::AddPlayerToMap(Player* player, bool initPlayer /*= true*/) houseInfo.House.AccessFlags, housing ? "yes" : "no"); player->SendDirectMessage(houseInfoPkt); + // Send SMSG_INITIATIVE_SERVICE_STATUS proactively so IsInitiativeEnabled() returns + // true immediately. Without this, the client waits for a poll response before showing + // initiative/endeavor UI elements. Sniff-verified: server responds with 0x80 (enabled). + { + WorldPackets::Housing::InitiativeServiceStatus initStatus; + initStatus.ServiceEnabled = true; + player->SendDirectMessage(initStatus.Write()); + } + // ENTER_PLOT must be sent AFTER SMSG_UPDATE_OBJECT creates the AT on the client. // UPDATE_OBJECT is flushed after AddPlayerToMap returns, so sending ENTER_PLOT // here synchronously would reference an AT GUID the client doesn't know yet.