diff --git a/sql/updates/characters/master/2026_03_10_00_characters.sql b/sql/updates/characters/master/2026_03_10_00_characters.sql new file mode 100644 index 000000000..ff6070465 --- /dev/null +++ b/sql/updates/characters/master/2026_03_10_00_characters.sql @@ -0,0 +1,39 @@ +DROP TABLE IF EXISTS `neighborhood_initiative_task_progress`; +CREATE TABLE `neighborhood_initiative_task_progress` ( + `initiativeDbId` BIGINT UNSIGNED NOT NULL COMMENT 'FK to neighborhood_initiatives.id', + `taskId` INT UNSIGNED NOT NULL COMMENT 'InitiativeTask DB2 entry ID', + `progress` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Current progress count towards TargetCount', + `status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=NOT_STARTED, 1=IN_PROGRESS, 2=COMPLETE', + PRIMARY KEY (`initiativeDbId`, `taskId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------------- +-- 13. neighborhood_initiative_milestones - Milestone reached/claimed tracking +-- +-- Tracks which milestones have been reached for each initiative instance, +-- and whether individual players have claimed their rewards. +-- --------------------------------------------------------------------------- +DROP TABLE IF EXISTS `neighborhood_initiative_milestones`; +CREATE TABLE `neighborhood_initiative_milestones` ( + `initiativeDbId` BIGINT UNSIGNED NOT NULL COMMENT 'FK to neighborhood_initiatives.id', + `milestoneIndex` INT UNSIGNED NOT NULL COMMENT 'Milestone index (0, 1, 2)', + `reached` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '1 = milestone has been reached', + `reachedTime` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix timestamp when milestone was reached', + PRIMARY KEY (`initiativeDbId`, `milestoneIndex`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------------- +-- 14. neighborhood_initiative_reward_claims - Per-player reward claim tracking +-- +-- Tracks which players have claimed rewards for which milestones. +-- Prevents double-claiming. +-- --------------------------------------------------------------------------- +DROP TABLE IF EXISTS `neighborhood_initiative_reward_claims`; +CREATE TABLE `neighborhood_initiative_reward_claims` ( + `initiativeDbId` BIGINT UNSIGNED NOT NULL COMMENT 'FK to neighborhood_initiatives.id', + `milestoneIndex` INT UNSIGNED NOT NULL COMMENT 'Milestone index (0, 1, 2)', + `playerGuid` BIGINT UNSIGNED NOT NULL COMMENT 'Player character GUID', + `claimTime` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix timestamp when reward was claimed', + PRIMARY KEY (`initiativeDbId`, `milestoneIndex`, `playerGuid`), + INDEX `idx_player` (`playerGuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/server/database/Database/Implementation/CharacterDatabase.cpp b/src/server/database/Database/Implementation/CharacterDatabase.cpp index 9122516da..08485966e 100644 --- a/src/server/database/Database/Implementation/CharacterDatabase.cpp +++ b/src/server/database/Database/Implementation/CharacterDatabase.cpp @@ -927,6 +927,33 @@ void CharacterDatabaseConnection::DoPrepareStatements() PrepareStatement(CHAR_DEL_NEIGHBORHOOD_INITIATIVE, "DELETE FROM neighborhood_initiatives WHERE id = ?", CONNECTION_ASYNC); PrepareStatement(CHAR_DEL_NEIGHBORHOOD_INITIATIVES, "DELETE FROM neighborhood_initiatives WHERE neighborhoodGuid = ?", CONNECTION_ASYNC); + // Neighborhood Initiative Task Progress (per-task persistence) + PrepareStatement(CHAR_SEL_INITIATIVE_TASK_PROGRESS, + "SELECT taskId, progress, status FROM neighborhood_initiative_task_progress WHERE initiativeDbId = ?", + CONNECTION_SYNCH); + PrepareStatement(CHAR_REP_INITIATIVE_TASK_PROGRESS, + "REPLACE INTO neighborhood_initiative_task_progress (initiativeDbId, taskId, progress, status) VALUES (?, ?, ?, ?)", + CONNECTION_ASYNC); + + // Neighborhood Initiative Milestones (reached/claimed tracking) + PrepareStatement(CHAR_SEL_INITIATIVE_MILESTONES, + "SELECT milestoneIndex, reached, reachedTime FROM neighborhood_initiative_milestones WHERE initiativeDbId = ?", + CONNECTION_SYNCH); + PrepareStatement(CHAR_REP_INITIATIVE_MILESTONE, + "REPLACE INTO neighborhood_initiative_milestones (initiativeDbId, milestoneIndex, reached, reachedTime) VALUES (?, ?, ?, ?)", + CONNECTION_ASYNC); + + // Neighborhood Initiative Reward Claims (per-player, per-milestone) + PrepareStatement(CHAR_SEL_INITIATIVE_REWARD_CLAIMS, + "SELECT milestoneIndex, playerGuid, claimTime FROM neighborhood_initiative_reward_claims WHERE initiativeDbId = ?", + CONNECTION_SYNCH); + PrepareStatement(CHAR_SEL_INITIATIVE_REWARD_CLAIM_PLAYER, + "SELECT milestoneIndex FROM neighborhood_initiative_reward_claims WHERE initiativeDbId = ? AND playerGuid = ?", + CONNECTION_SYNCH); + PrepareStatement(CHAR_INS_INITIATIVE_REWARD_CLAIM, + "INSERT IGNORE INTO neighborhood_initiative_reward_claims (initiativeDbId, milestoneIndex, playerGuid, claimTime) VALUES (?, ?, ?, ?)", + CONNECTION_ASYNC); + // Neighborhood Initiative Contributions (per-player tracking) PrepareStatement(CHAR_INS_INITIATIVE_CONTRIBUTION, "INSERT INTO neighborhood_initiative_contributions (initiativeDbId, playerGuid, taskId, amount, lastUpdated) " diff --git a/src/server/database/Database/Implementation/CharacterDatabase.h b/src/server/database/Database/Implementation/CharacterDatabase.h index db86fcb12..42997710f 100644 --- a/src/server/database/Database/Implementation/CharacterDatabase.h +++ b/src/server/database/Database/Implementation/CharacterDatabase.h @@ -780,6 +780,19 @@ enum CharacterDatabaseStatements : uint32 CHAR_DEL_NEIGHBORHOOD_INITIATIVE, CHAR_DEL_NEIGHBORHOOD_INITIATIVES, + // Neighborhood Initiative Task Progress (per-task persistence) + CHAR_SEL_INITIATIVE_TASK_PROGRESS, + CHAR_REP_INITIATIVE_TASK_PROGRESS, + + // Neighborhood Initiative Milestones (reached/claimed tracking) + CHAR_SEL_INITIATIVE_MILESTONES, + CHAR_REP_INITIATIVE_MILESTONE, + + // Neighborhood Initiative Reward Claims (per-player, per-milestone) + CHAR_SEL_INITIATIVE_REWARD_CLAIMS, + CHAR_SEL_INITIATIVE_REWARD_CLAIM_PLAYER, + CHAR_INS_INITIATIVE_REWARD_CLAIM, + // Neighborhood Initiative Contributions (per-player tracking) CHAR_INS_INITIATIVE_CONTRIBUTION, CHAR_SEL_INITIATIVE_CONTRIBUTIONS, diff --git a/src/server/game/AI/SmartScripts/SmartScriptMgr.cpp b/src/server/game/AI/SmartScripts/SmartScriptMgr.cpp index 93f32910a..fd83b0721 100644 --- a/src/server/game/AI/SmartScripts/SmartScriptMgr.cpp +++ b/src/server/game/AI/SmartScripts/SmartScriptMgr.cpp @@ -1929,7 +1929,7 @@ bool SmartAIMgr::IsEventValid(SmartScriptHolder& e) CacheSpellContainerBounds sBounds = GetSummonGameObjectSpellContainerBounds(e.action.summonGO.entry); for (CacheSpellContainer::const_iterator itr = sBounds.first; itr != sBounds.second; ++itr) - TC_LOG_ERROR("sql.sql", "SmartAIMgr: Entry {} SourceType {} Event {} Action {} gameobject summon: There is a summon spell for gameobject entry {} (SpellId: {}, effect: {})", + TC_LOG_DEBUG("sql.sql", "SmartAIMgr: Entry {} SourceType {} Event {} Action {} gameobject summon: There is a summon spell for gameobject entry {} (SpellId: {}, effect: {})", e.entryOrGuid, e.GetScriptType(), e.event_id, e.GetActionType(), e.action.summonGO.entry, itr->second.first, itr->second.second); break; } @@ -1950,7 +1950,7 @@ bool SmartAIMgr::IsEventValid(SmartScriptHolder& e) CacheSpellContainerBounds sBounds = GetCreateItemSpellContainerBounds(e.action.item.entry); for (CacheSpellContainer::const_iterator itr = sBounds.first; itr != sBounds.second; ++itr) - TC_LOG_ERROR("sql.sql", "SmartAIMgr: Entry {} SourceType {} Event {} Action {} Create Item: There is a create item spell for item {} (SpellId: {} effect: {})", + TC_LOG_DEBUG("sql.sql", "SmartAIMgr: Entry {} SourceType {} Event {} Action {} Create Item: There is a create item spell for item {} (SpellId: {} effect: {})", e.entryOrGuid, e.GetScriptType(), e.event_id, e.GetActionType(), e.action.item.entry, itr->second.first, itr->second.second); break; } diff --git a/src/server/game/Handlers/NeighborhoodHandler.cpp b/src/server/game/Handlers/NeighborhoodHandler.cpp index c316ac132..58a5e4cdc 100644 --- a/src/server/game/Handlers/NeighborhoodHandler.cpp +++ b/src/server/game/Handlers/NeighborhoodHandler.cpp @@ -2065,14 +2065,22 @@ void WorldSession::HandleGetInitiativeClaimRewardRequest(WorldPackets::Neighborh uint64 nhGuid = neighborhood->GetGuid().GetCounter(); - if (!sInitiativeManager.HasUnclaimedRewards(nhGuid, packet.InitiativeID)) + uint64 playerLowGuid = player->GetGUID().GetCounter(); + + if (!sInitiativeManager.HasUnclaimedRewards(nhGuid, packet.InitiativeID, playerLowGuid)) + { + sInitiativeManager.SendInitiativeRewardsResult(this, static_cast(HOUSING_RESULT_GENERIC_FAILURE)); + return; + } + + // Claim the milestone reward ? grants items/currency via DB2 reward chain and + // persists the claim so it can't be double-claimed + if (!sInitiativeManager.ClaimMilestoneReward(nhGuid, packet.InitiativeID, packet.MilestoneIndex, player)) { sInitiativeManager.SendInitiativeRewardsResult(this, static_cast(HOUSING_RESULT_GENERIC_FAILURE)); return; } - // Reward claiming acknowledged ? actual item/currency rewards would be granted via - // InitiativeMilestone DB2 RewardID ? reward table. For now, acknowledge success. sInitiativeManager.SendInitiativeRewardsResult(this, static_cast(HOUSING_RESULT_SUCCESS)); // Update favor for the contributing player @@ -2122,14 +2130,36 @@ void WorldSession::HandleGetInitiativeOpenChestRequest(WorldPackets::Neighborhoo uint64 nhGuid = neighborhood->GetGuid().GetCounter(); - // Chest opening is a variant of reward claiming tied to milestone completion - if (!sInitiativeManager.HasUnclaimedRewards(nhGuid, packet.InitiativeID)) + // Chest opening is a variant of reward claiming tied to milestone completion. + // Find the first unclaimed milestone for this player and claim it. + uint64 playerLowGuid = player->GetGUID().GetCounter(); + + if (!sInitiativeManager.HasUnclaimedRewards(nhGuid, packet.InitiativeID, playerLowGuid)) { sInitiativeManager.SendInitiativeRewardsResult(this, static_cast(HOUSING_RESULT_GENERIC_FAILURE)); return; } - sInitiativeManager.SendInitiativeRewardsResult(this, static_cast(HOUSING_RESULT_SUCCESS)); + // Find the first unclaimed reached milestone and claim it + ActiveInitiative* active = sInitiativeManager.GetActiveInitiative(nhGuid); + bool claimed = false; + if (active) + { + for (auto const& [msIndex, reached] : active->MilestonesReached) + { + if (!reached) + continue; + auto claimItr = active->RewardClaims.find(msIndex); + if (claimItr == active->RewardClaims.end() || claimItr->second.find(playerLowGuid) == claimItr->second.end()) + { + claimed = sInitiativeManager.ClaimMilestoneReward(nhGuid, packet.InitiativeID, msIndex, player); + break; + } + } + } + + sInitiativeManager.SendInitiativeRewardsResult(this, static_cast( + claimed ? HOUSING_RESULT_SUCCESS : HOUSING_RESULT_GENERIC_FAILURE)); } void WorldSession::HandleGetInitiativeTaskAcceptRequest(WorldPackets::Neighborhood::GetInitiativeTaskAcceptRequest const& packet) diff --git a/src/server/game/Housing/InitiativeManager.cpp b/src/server/game/Housing/InitiativeManager.cpp index d6018df8b..8359f0695 100644 --- a/src/server/game/Housing/InitiativeManager.cpp +++ b/src/server/game/Housing/InitiativeManager.cpp @@ -19,10 +19,12 @@ #include "CharacterDatabase.h" #include "Housing.h" #include "DB2Stores.h" +#include "Item.h" #include "DatabaseEnv.h" #include "GameTime.h" #include "HousingDefines.h" #include "HousingPackets.h" +#include "Item.h" #include "Log.h" #include "Neighborhood.h" #include "NeighborhoodMgr.h" @@ -82,8 +84,8 @@ void InitiativeManager::BuildDB2IndexMaps() // Sort tasks by SortOrder within each initiative for (auto& [initId, tasks] : _initiativeTasks) std::sort(tasks.begin(), tasks.end(), [](InitiativeTaskData const& a, InitiativeTaskData const& b) { - return a.SortOrder < b.SortOrder; - }); + return a.SortOrder < b.SortOrder; + }); // Build CycleID -> Milestones map _cycleMilestones.clear(); @@ -104,8 +106,8 @@ void InitiativeManager::BuildDB2IndexMaps() // Sort milestones by index within each cycle for (auto& [cycleId, milestones] : _cycleMilestones) std::sort(milestones.begin(), milestones.end(), [](InitiativeMilestoneData const& a, InitiativeMilestoneData const& b) { - return a.MilestoneIndex < b.MilestoneIndex; - }); + return a.MilestoneIndex < b.MilestoneIndex; + }); // Build InitiativeID -> active CycleID map (pick lowest CycleIndex as the "active" cycle) _initiativeActiveCycle.clear(); @@ -157,7 +159,7 @@ void InitiativeManager::LoadFromDB() initiative->Progress = fields[4].GetFloat(); initiative->Completed = fields[5].GetUInt8() != 0; - // Initialize task progress from DB2 data + // Initialize task progress from DB2 data (defaults) auto const& tasks = GetTasksForInitiative(initiative->InitiativeID); for (auto const& taskData : tasks) { @@ -167,13 +169,57 @@ void InitiativeManager::LoadFromDB() progress.Status = initiative->Completed ? INITIATIVE_TASK_STATUS_COMPLETE : INITIATIVE_TASK_STATUS_NOT_STARTED; } - // Initialize milestone tracking + // Load persisted task progress (overwrites defaults with saved state) + if (initiative->DbId) + { + CharacterDatabasePreparedStatement* taskStmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_INITIATIVE_TASK_PROGRESS); + taskStmt->setUInt64(0, initiative->DbId); + PreparedQueryResult taskResult = CharacterDatabase.Query(taskStmt); + if (taskResult) + { + do + { + Field* f = taskResult->Fetch(); + uint32 taskId = f[0].GetUInt32(); + uint32 progress = f[1].GetUInt32(); + uint8 status = f[2].GetUInt8(); + + auto tItr = initiative->TaskProgress.find(taskId); + if (tItr != initiative->TaskProgress.end()) + { + tItr->second.Progress = progress; + tItr->second.Status = static_cast(std::min(status, 2)); + } + } while (taskResult->NextRow()); + } + } + + // Load persisted milestone state uint32 cycleID = GetActiveCycleForInitiative(initiative->InitiativeID); if (cycleID) { + // Initialize defaults from progress float auto const& milestones = GetMilestonesForCycle(cycleID); for (auto const& milestone : milestones) initiative->MilestonesReached[milestone.MilestoneIndex] = (initiative->Progress >= milestone.ProgressRequired); + + // Overwrite with persisted milestone state + if (initiative->DbId) + { + CharacterDatabasePreparedStatement* msStmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_INITIATIVE_MILESTONES); + msStmt->setUInt64(0, initiative->DbId); + PreparedQueryResult msResult = CharacterDatabase.Query(msStmt); + if (msResult) + { + do + { + Field* f = msResult->Fetch(); + uint32 milestoneIdx = f[0].GetUInt32(); + bool reached = f[1].GetUInt8() != 0; + initiative->MilestonesReached[milestoneIdx] = reached; + } while (msResult->NextRow()); + } + } } // Load per-player contributions for this initiative @@ -188,11 +234,26 @@ void InitiativeManager::LoadFromDB() { Field* f = contribResult->Fetch(); uint64 playerGuid = f[0].GetUInt64(); - uint32 taskId = f[1].GetUInt32(); - uint32 amount = f[2].GetUInt32(); + uint32 taskId = f[1].GetUInt32(); + uint32 amount = f[2].GetUInt32(); initiative->PlayerContributions[playerGuid][taskId] = amount; } while (contribResult->NextRow()); } + + // Load per-player reward claims + CharacterDatabasePreparedStatement* claimStmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_INITIATIVE_REWARD_CLAIMS); + claimStmt->setUInt64(0, initiative->DbId); + PreparedQueryResult claimResult = CharacterDatabase.Query(claimStmt); + if (claimResult) + { + do + { + Field* f = claimResult->Fetch(); + uint32 milestoneIdx = f[0].GetUInt32(); + uint64 claimPlayer = f[1].GetUInt64(); + initiative->RewardClaims[milestoneIdx].insert(claimPlayer); + } while (claimResult->NextRow()); + } } TC_LOG_DEBUG("housing", "InitiativeManager::LoadFromDB: Loaded initiative {} (DB2 ID {}) for neighborhood {} - progress={:.2f} completed={} contributors={}", @@ -371,11 +432,12 @@ void InitiativeManager::CompleteInitiative(uint64 neighborhoodGuid, uint32 initi initiative->Completed = true; initiative->Progress = 1.0f; - // Mark all tasks complete + // Mark all tasks complete and persist for (auto& [taskId, taskProgress] : initiative->TaskProgress) taskProgress.Status = INITIATIVE_TASK_STATUS_COMPLETE; PersistInitiative(*initiative); + PersistTaskProgress(*initiative); // Broadcast completion to neighborhood ObjectGuid nhObjGuid = ObjectGuid::Create(4, 0, 0, neighborhoodGuid); @@ -444,6 +506,9 @@ void InitiativeManager::UpdateTaskProgress(uint64 neighborhoodGuid, uint32 initi UpdatePlayerInitiativeFavor(contributor, neighborhoodGuid); } + // Persist individual task progress to DB + PersistSingleTaskProgress(initiative->DbId, taskID, taskProgress.Progress, static_cast(taskProgress.Status)); + TC_LOG_DEBUG("housing", "InitiativeManager::UpdateTaskProgress: Task {} in initiative {} progress: {}/{} (contributor: {})", taskID, initiativeID, taskProgress.Progress, targetCount, contributor ? contributor->GetGUID().ToString() : "none"); @@ -452,6 +517,7 @@ void InitiativeManager::UpdateTaskProgress(uint64 neighborhoodGuid, uint32 initi if (static_cast(taskProgress.Progress) >= targetCount) { taskProgress.Status = INITIATIVE_TASK_STATUS_COMPLETE; + PersistSingleTaskProgress(initiative->DbId, taskID, taskProgress.Progress, static_cast(taskProgress.Status)); ObjectGuid nhObjGuid = ObjectGuid::Create(4, 0, 0, neighborhoodGuid); Neighborhood* neighborhood = sNeighborhoodMgr.GetNeighborhood(nhObjGuid); @@ -524,6 +590,7 @@ void InitiativeManager::ClearTaskCriteria(uint64 neighborhoodGuid, uint32 initia taskItr->second.Progress = 0; taskItr->second.Status = INITIATIVE_TASK_STATUS_NOT_STARTED; + PersistSingleTaskProgress(initiative->DbId, taskID, 0, static_cast(INITIATIVE_TASK_STATUS_NOT_STARTED)); PersistInitiative(*initiative); TC_LOG_DEBUG("housing", "InitiativeManager::ClearTaskCriteria: Cleared task {} in initiative {} (neighborhood {})", @@ -577,7 +644,7 @@ void InitiativeManager::OnPlayerAction(Player* player, int32 taskType, uint32 co } } -bool InitiativeManager::HasUnclaimedRewards(uint64 neighborhoodGuid, uint32 initiativeID) const +bool InitiativeManager::HasUnclaimedRewards(uint64 neighborhoodGuid, uint32 initiativeID, uint64 playerGuid) const { auto itr = _activeInitiatives.find(neighborhoodGuid); if (itr == _activeInitiatives.end()) @@ -588,16 +655,72 @@ bool InitiativeManager::HasUnclaimedRewards(uint64 neighborhoodGuid, uint32 init if (initiative->InitiativeID != initiativeID) continue; - // Check if any milestones have been reached + // Check if any reached milestone has NOT been claimed by this player for (auto const& [index, reached] : initiative->MilestonesReached) { - if (reached) - return true; + if (!reached) + continue; + + // Check if this player already claimed this milestone + auto claimItr = initiative->RewardClaims.find(index); + if (claimItr == initiative->RewardClaims.end() || claimItr->second.find(playerGuid) == claimItr->second.end()) + return true; // Reached but not claimed by this player } } return false; } +bool InitiativeManager::ClaimMilestoneReward(uint64 neighborhoodGuid, uint32 initiativeID, uint32 milestoneIndex, Player* player) +{ + if (!player) + return false; + + uint64 playerGuid = player->GetGUID().GetCounter(); + + auto itr = _activeInitiatives.find(neighborhoodGuid); + if (itr == _activeInitiatives.end()) + return false; + + for (auto& initiative : itr->second) + { + if (initiative->InitiativeID != initiativeID) + continue; + + // Verify milestone is reached + auto msItr = initiative->MilestonesReached.find(milestoneIndex); + if (msItr == initiative->MilestonesReached.end() || !msItr->second) + return false; + + // Check not already claimed + if (initiative->RewardClaims[milestoneIndex].count(playerGuid)) + return false; + + // Record the claim + initiative->RewardClaims[milestoneIndex].insert(playerGuid); + PersistRewardClaim(initiative->DbId, milestoneIndex, playerGuid); + + // Find the milestone DB2 entry to look up rewards + uint32 cycleID = GetActiveCycleForInitiative(initiativeID); + if (cycleID) + { + auto const& milestones = GetMilestonesForCycle(cycleID); + for (auto const& ms : milestones) + { + if (static_cast(ms.MilestoneIndex) == milestoneIndex) + { + GrantMilestoneRewards(player, ms.MilestoneID); + break; + } + } + } + + TC_LOG_INFO("housing", "InitiativeManager::ClaimMilestoneReward: Player {} claimed milestone {} reward for initiative {} in neighborhood {}", + player->GetGUID().ToString(), milestoneIndex, initiativeID, neighborhoodGuid); + return true; + } + return false; +} + // ============================================================ // Packet sending helpers // ============================================================ @@ -833,11 +956,114 @@ void InitiativeManager::PersistInitiative(ActiveInitiative const& initiative) CharacterDatabase.Execute(stmt); } -void InitiativeManager::PersistTaskProgress(ActiveInitiative const& /*initiative*/) +void InitiativeManager::PersistTaskProgress(ActiveInitiative const& initiative) { - // Task progress is currently tracked in-memory only. - // For a full implementation, this would persist per-task progress to a separate table. - // The current neighborhood_initiatives table only stores overall progress float. + if (initiative.DbId == 0) + return; + + for (auto const& [taskId, taskProgress] : initiative.TaskProgress) + PersistSingleTaskProgress(initiative.DbId, taskId, taskProgress.Progress, static_cast(taskProgress.Status)); +} + +void InitiativeManager::PersistSingleTaskProgress(uint64 initiativeDbId, uint32 taskId, uint32 progress, uint8 status) +{ + if (initiativeDbId == 0) + return; + + CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_REP_INITIATIVE_TASK_PROGRESS); + uint8 index = 0; + stmt->setUInt64(index++, initiativeDbId); + stmt->setUInt32(index++, taskId); + stmt->setUInt32(index++, progress); + stmt->setUInt8(index++, status); + CharacterDatabase.Execute(stmt); +} + +void InitiativeManager::PersistMilestoneReached(uint64 initiativeDbId, uint32 milestoneIndex, uint32 reachedTime) +{ + if (initiativeDbId == 0) + return; + + CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_REP_INITIATIVE_MILESTONE); + uint8 index = 0; + stmt->setUInt64(index++, initiativeDbId); + stmt->setUInt32(index++, milestoneIndex); + stmt->setUInt8(index++, 1); // reached = true + stmt->setUInt32(index++, reachedTime); + CharacterDatabase.Execute(stmt); +} + +void InitiativeManager::PersistRewardClaim(uint64 initiativeDbId, uint32 milestoneIndex, uint64 playerGuid) +{ + if (initiativeDbId == 0) + return; + + CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_INS_INITIATIVE_REWARD_CLAIM); + uint8 index = 0; + stmt->setUInt64(index++, initiativeDbId); + stmt->setUInt32(index++, milestoneIndex); + stmt->setUInt64(index++, playerGuid); + stmt->setUInt32(index++, static_cast(GameTime::GetGameTime())); + CharacterDatabase.Execute(stmt); +} + +void InitiativeManager::GrantMilestoneRewards(Player* player, uint32 milestoneID) +{ + if (!player) + return; + + // Walk the InitiativeRewardXMilestone join table to find rewards for this milestone + for (InitiativeRewardXMilestoneEntry const* link : sInitiativeRewardXMilestoneStore) + { + if (!link || link->InitiativeMilestoneID != milestoneID) + continue; + + InitiativeRewardEntry const* reward = sInitiativeRewardStore.LookupEntry(link->InitiativeRewardID); + if (!reward) + continue; + + // Grant based on reward type + if (reward->CurrencyID > 0 && reward->RewardAmount > 0) + { + player->ModifyCurrency(reward->CurrencyID, reward->RewardAmount, CurrencyGainSource::QuestReward); + TC_LOG_DEBUG("housing", "InitiativeManager::GrantMilestoneRewards: Granted {} currency {} to player {}", + reward->RewardAmount, reward->CurrencyID, player->GetGUID().ToString()); + } + + if (reward->ItemID > 0 && reward->RewardAmount > 0) + { + // Add item(s) to player's inventory + ItemPosCountVec dest; + InventoryResult msg = player->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, reward->ItemID, reward->RewardAmount); + if (msg == EQUIP_ERR_OK) + { + if (Item* item = player->StoreNewItem(dest, reward->ItemID, true)) + player->SendNewItem(item, reward->RewardAmount, true, false); + + TC_LOG_DEBUG("housing", "InitiativeManager::GrantMilestoneRewards: Granted {}x item {} to player {}", + reward->RewardAmount, reward->ItemID, player->GetGUID().ToString()); + } + else + { + // Send by mail if inventory is full + player->SendEquipError(msg, nullptr, nullptr, reward->ItemID); + TC_LOG_DEBUG("housing", "InitiativeManager::GrantMilestoneRewards: Player {} inventory full for item {}, sending by mail", + player->GetGUID().ToString(), reward->ItemID); + } + } + + // If reward has favor/general amount but no specific currency/item, treat as favor + if (reward->CurrencyID == 0 && reward->ItemID == 0 && reward->RewardAmount > 0) + { + Housing* housing = player->GetHousing(); + if (housing) + { + housing->AddFavor(static_cast(reward->RewardAmount), HOUSING_FAVOR_SOURCE_INITIATIVE_CHEST); + TC_LOG_DEBUG("housing", "InitiativeManager::GrantMilestoneRewards: Granted {} favor to player {}", + reward->RewardAmount, player->GetGUID().ToString()); + } + } + } } void InitiativeManager::PersistContribution(uint64 initiativeDbId, uint64 playerGuid, uint32 taskId, uint32 amount) @@ -907,7 +1133,7 @@ std::vector> InitiativeManager::GetTopContributors( // Sort descending by contribution amount std::sort(result.begin(), result.end(), [](auto const& a, auto const& b) { return a.second > b.second; - }); + }); // Trim to limit if (limit > 0 && result.size() > limit) @@ -944,6 +1170,8 @@ void InitiativeManager::CheckMilestones(ActiveInitiative& initiative, Neighborho if (isReached && !wasReached) { initiative.MilestonesReached[milestone.MilestoneIndex] = true; + PersistMilestoneReached(initiative.DbId, milestone.MilestoneIndex, static_cast(GameTime::GetGameTime())); + if (neighborhood) BroadcastRewardAvailable(neighborhood, initiative.InitiativeID, milestone.MilestoneIndex); diff --git a/src/server/game/Housing/InitiativeManager.h b/src/server/game/Housing/InitiativeManager.h index 71aad22ee..2700dbad6 100644 --- a/src/server/game/Housing/InitiativeManager.h +++ b/src/server/game/Housing/InitiativeManager.h @@ -21,6 +21,7 @@ #include "Define.h" #include "ObjectGuid.h" #include +#include #include #include @@ -31,9 +32,9 @@ class WorldSession; // Status of an individual task within an initiative enum InitiativeTaskStatus : uint32 { - INITIATIVE_TASK_STATUS_NOT_STARTED = 0, - INITIATIVE_TASK_STATUS_IN_PROGRESS = 1, - INITIATIVE_TASK_STATUS_COMPLETE = 2 + INITIATIVE_TASK_STATUS_NOT_STARTED = 0, + INITIATIVE_TASK_STATUS_IN_PROGRESS = 1, + INITIATIVE_TASK_STATUS_COMPLETE = 2 }; // Tracks a single task's progress for a player within a neighborhood initiative @@ -62,6 +63,9 @@ struct ActiveInitiative // Per-player contribution tracking: playerGuid -> taskId -> amount std::unordered_map> PlayerContributions; + + // Per-player reward claims: milestoneIndex -> set of playerGuids who claimed + std::unordered_map> RewardClaims; }; // Cached DB2 data for an initiative's tasks @@ -114,12 +118,13 @@ public: void UpdateTaskProgress(uint64 neighborhoodGuid, uint32 initiativeID, uint32 taskID, uint32 progressDelta, Player* contributor); void ClearTaskCriteria(uint64 neighborhoodGuid, uint32 initiativeID, uint32 taskID); - // Gameplay trigger — called from Player/SpellEffects hooks + // Gameplay trigger ? called from Player/SpellEffects hooks // taskType matches InitiativeTask.TaskType: 1=Gathering, 2=Crafting, 3=Combat, 4=Exploration void OnPlayerAction(Player* player, int32 taskType, uint32 count = 1); - // Reward queries - bool HasUnclaimedRewards(uint64 neighborhoodGuid, uint32 initiativeID) const; + // Reward queries and distribution + bool HasUnclaimedRewards(uint64 neighborhoodGuid, uint32 initiativeID, uint64 playerGuid) const; + bool ClaimMilestoneReward(uint64 neighborhoodGuid, uint32 initiativeID, uint32 milestoneIndex, Player* player); // Per-player contribution queries uint32 GetPlayerContribution(uint64 neighborhoodGuid, uint32 initiativeID, uint64 playerGuid) const; @@ -145,8 +150,12 @@ private: void PersistInitiative(ActiveInitiative const& initiative); void PersistTaskProgress(ActiveInitiative const& initiative); + void PersistSingleTaskProgress(uint64 initiativeDbId, uint32 taskId, uint32 progress, uint8 status); + void PersistMilestoneReached(uint64 initiativeDbId, uint32 milestoneIndex, uint32 reachedTime); + void PersistRewardClaim(uint64 initiativeDbId, uint32 milestoneIndex, uint64 playerGuid); void PersistContribution(uint64 initiativeDbId, uint64 playerGuid, uint32 taskId, uint32 amount); void CheckMilestones(ActiveInitiative& initiative, Neighborhood* neighborhood); + void GrantMilestoneRewards(Player* player, uint32 milestoneID); // Active initiatives: neighborhoodGuid -> list of active initiatives std::unordered_map>> _activeInitiatives;