From c1cbd89c460960ada68a258e25bfe568fcd837a6 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 12:14:21 +0100 Subject: [PATCH 01/18] fix(server): restore mangosd self-test entry point --- src/mangosd/Master.cpp | 14 +++++++++++++- src/mangosd/Master.h | 3 ++- src/mangosd/mangosd.cpp | 8 +++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/mangosd/Master.cpp b/src/mangosd/Master.cpp index 1a6d08029..0d0579155 100644 --- a/src/mangosd/Master.cpp +++ b/src/mangosd/Master.cpp @@ -36,6 +36,7 @@ #include "DBCStores.h" #include "Database/DatabaseEnv.h" #include "Log.h" +#include "MangosdTest.h" #include "MapManager.h" #include "Server/WorldNetwork.h" #include "SystemConfig.h" @@ -56,6 +57,8 @@ extern int m_ServiceStatus; #endif #include +#include +#include #include #include #include @@ -379,13 +382,22 @@ void Master::ShutdownWorld() sMapMgr.UnloadAll(); } -int Master::Run() +int Master::Run(std::string const& testMode) { if (!StartDatabases()) { return 1; } + if (!testMode.empty()) + { + int const rc = RunMangosdTest(testMode); + sLog.outString("mangosd test '%s' exit %d", testMode.c_str(), rc); + sLog.Flush(); + fflush(stdout); + _exit(rc); + } + ClearOnlineAccounts(); if (!warden::WardenCheckCatalogLoader().LoadAndPublish()) diff --git a/src/mangosd/Master.h b/src/mangosd/Master.h index b728b017e..d1f8da356 100644 --- a/src/mangosd/Master.h +++ b/src/mangosd/Master.h @@ -31,6 +31,7 @@ #include "Platform/Define.h" #include +#include #include /** @@ -63,7 +64,7 @@ class Master * * @return The process exit code. */ - int Run(); + int Run(std::string const& testMode = ""); private: diff --git a/src/mangosd/mangosd.cpp b/src/mangosd/mangosd.cpp index 56da7dc68..414bb13a0 100644 --- a/src/mangosd/mangosd.cpp +++ b/src/mangosd/mangosd.cpp @@ -161,6 +161,7 @@ static void usage(const char* prog) " -v, --version print version and exist\n\r" " -c use config_file as configuration file\n\r" " -a, --ahbot use config_file as ahbot configuration file\n\r" + " -t run an in-process self-test and exit\n\r" #ifdef WIN32 " Running as service functions:\n\r" " -s run run as service\n\r" @@ -204,6 +205,7 @@ int main(int argc, char** argv) char const* cfg_file = MANGOSD_CONFIG_LOCATION; char serviceDaemonMode = '\0'; + std::string testMode; // Walked by hand rather than with ACE_Get_Opt (gone with the rest of ACE) or // getopt (absent on MSVC). Four options do not justify a dependency. @@ -225,6 +227,10 @@ int main(int argc, char** argv) { sAuctionBotConfig.SetConfigFileName(argv[++i]); } + else if (arg == "-t" && hasValue) + { + testMode = argv[++i]; + } else if (arg == "-s" && hasValue) { const std::string mode = argv[++i]; @@ -376,7 +382,7 @@ int main(int argc, char** argv) // runs the world loop on this thread and returns once the world has stopped // and every service has been joined. Master master; - const int runCode = master.Run(); + const int runCode = master.Run(testMode); ///- Remove signal handling before leaving unhook_signals(); From 3b0663e920e52337d8da8e35b88334d346897ead Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 12:14:27 +0100 Subject: [PATCH 02/18] fix(ah): add authoritative custody snapshot reads --- src/game/AuctionHouseBot/CustodyLedger.cpp | 92 ++++++++++++++ src/game/AuctionHouseBot/CustodyLedger.h | 30 +++++ src/mangosd/MangosdTest.cpp | 139 +++++++++++++++++++++ 3 files changed, 261 insertions(+) diff --git a/src/game/AuctionHouseBot/CustodyLedger.cpp b/src/game/AuctionHouseBot/CustodyLedger.cpp index 5120071dd..79a35d65d 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.cpp +++ b/src/game/AuctionHouseBot/CustodyLedger.cpp @@ -92,6 +92,98 @@ void CustodyLedger::SetAmount(std::string const& idemKey, uint32 newAmount) newAmount, key.c_str()); } +CustodyRouteState CustodyLedger::GetRouteState(uint32 auctionId) +{ + CustodyRouteState route = {}; + QueryResult* result = CharacterDatabase.PQuery( + "SELECT " + "COALESCE(MAX(`idem_key` LIKE 'botlist:%%'),0)," + "COALESCE(MAX(`idem_key` IN ('item:%u','dep:%u') " + "OR `role` IN (%u,%u)),0)," + "COALESCE(MAX(`role`=%u),0) " + "FROM `custody_ledger` WHERE `auction_id`=%u AND `state`=%u", + auctionId, auctionId, uint32(ROLE_ITEM), uint32(ROLE_DEPOSIT), + uint32(ROLE_BID), auctionId, uint32(CST_RESERVED)); + if (!result) + { + return route; + } + + Field* fields = result->Fetch(); + bool const hasMarker = fields[0].GetUInt32() != 0; + bool const hasSellerCandidate = fields[1].GetUInt32() != 0; + route.usesPlayerSellerCustody = hasSellerCandidate && !hasMarker; + route.hasLiveBidCustody = fields[2].GetUInt32() != 0; + delete result; + return route; +} + +void CustodyLedger::LoadReconcileSnapshot(std::vector& out) +{ + out.clear(); + QueryResult* result = CharacterDatabase.Query( + "SELECT c.`id`,c.`idem_key`,c.`kind`,c.`role`,c.`state`," + "c.`owner_guid`,c.`beneficiary_guid`,c.`amount`,c.`item_guid`," + "c.`auction_id`,c.`created_time`,c.`resolved_time`," + "a.`id`,a.`itemguid`,a.`itemowner`,a.`buyguid`,a.`lastbid`,a.`deposit` " + "FROM `custody_ledger` c " + "LEFT JOIN `auction` a ON a.`id`=c.`auction_id` " + "WHERE c.`state`=0 ORDER BY c.`auction_id`,c.`id`"); + if (!result) + { + return; + } + + CustodySnapshotGroup group = {}; + do + { + Field* fields = result->Fetch(); + CustodyRow row; + FillRow(fields, row); + + if (!group.rows.empty() && group.auctionId != row.auctionId) + { + out.push_back(group); + group = CustodySnapshotGroup(); + } + + if (group.rows.empty()) + { + group.auctionId = row.auctionId; + group.auction.exists = fields[12].GetUInt32() != 0; + if (group.auction.exists) + { + group.auction.auctionId = fields[12].GetUInt32(); + group.auction.itemGuid = fields[13].GetUInt32(); + group.auction.ownerGuid = fields[14].GetUInt32(); + group.auction.bidderGuid = fields[15].GetUInt32(); + group.auction.bid = fields[16].GetUInt32(); + group.auction.deposit = fields[17].GetUInt32(); + } + } + group.rows.push_back(row); + } + while (result->NextRow()); + + if (!group.rows.empty()) + { + out.push_back(group); + } + delete result; +} + +bool CustodyLedger::AuctionExists(uint32 auctionId) +{ + QueryResult* result = CharacterDatabase.PQuery( + "SELECT 1 FROM `auction` WHERE `id`=%u LIMIT 1", auctionId); + if (!result) + { + return false; + } + delete result; + return true; +} + bool CustodyLedger::HasRows(uint32 auctionId) { // Only match active (CST_RESERVED) rows so terminal leftovers from a diff --git a/src/game/AuctionHouseBot/CustodyLedger.h b/src/game/AuctionHouseBot/CustodyLedger.h index 7d65224ae..64027dde8 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.h +++ b/src/game/AuctionHouseBot/CustodyLedger.h @@ -95,6 +95,30 @@ struct CustodyRow uint64 resolvedTime; ///< Unix timestamp at resolution (0 = unresolved). }; +struct CustodyAuctionFacts +{ + bool exists; + uint32 auctionId; + uint32 itemGuid; + uint32 ownerGuid; + uint32 bidderGuid; + uint32 bid; + uint32 deposit; +}; + +struct CustodySnapshotGroup +{ + uint32 auctionId; + CustodyAuctionFacts auction; + std::vector rows; +}; + +struct CustodyRouteState +{ + bool usesPlayerSellerCustody; + bool hasLiveBidCustody; +}; + /** * @brief Persistent CRUD namespace for the custody_ledger table. * @@ -103,6 +127,12 @@ struct CustodyRow */ namespace CustodyLedger { + CustodyRouteState GetRouteState(uint32 auctionId); + + void LoadReconcileSnapshot(std::vector& out); + + bool AuctionExists(uint32 auctionId); + /** * @brief Append an INSERT for @p row to the caller's open transaction. * diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 1341494b8..c3b615204 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -396,6 +396,145 @@ static int RunCustodyTest() "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:crud%%'"); CharacterDatabase.CommitTransactionChecked(); + // =================================================== authoritative reads + // These rows exercise route provenance independently from local AH maps. + uint32 const routeBase = 973100; + uint32 const snapshotAuctionId = routeBase + 7; + uint32 const orphanAuctionId = routeBase + 8; + uint64 const routeNow = static_cast(time(NULL)); + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id` BETWEEN %u AND %u", + routeBase + 1, orphanAuctionId); + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", snapshotAuctionId); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) VALUES " + "('item:973101',1,3,0,1101,0,0,883101,973101," UI64FMTD ",0)," + "('dep:973101',0,0,0,1101,0,31,0,973101," UI64FMTD ",0)," + "('bid:973102:1',0,1,0,2102,0,102,0,973102," UI64FMTD ",0)," + "('resolve:test:973103',0,4,0,0,0,0,0,973103," UI64FMTD ",0)," + "('botlist:test:973104',1,3,0,4294967294,0,0,883104,973104," UI64FMTD ",0)," + "('botlist:test:973105',1,3,0,4294967294,0,0,883105,973105," UI64FMTD ",0)," + "('bid:973105:1',0,1,0,2105,0,105,0,973105," UI64FMTD ",0)," + "('botlist:test:973106',1,3,0,4294967294,0,0,883106,973106," UI64FMTD ",0)," + "('item:973106',1,3,0,4294967294,0,0,883106,973106," UI64FMTD ",0)," + "('test:snapshot:live',0,4,0,0,0,0,0,973107," UI64FMTD ",0)," + "('test:snapshot:orphan',0,1,0,2208,0,208,0,973108," UI64FMTD ",0)", + routeNow, routeNow, routeNow, routeNow, routeNow, routeNow, + routeNow, routeNow, routeNow, routeNow, routeNow); + CharacterDatabase.PExecute( + "INSERT INTO `auction` " + "(`id`,`houseid`,`itemguid`,`item_template`,`item_count`," + "`item_randompropertyid`,`itemowner`,`buyoutprice`,`time`,`buyguid`," + "`lastbid`,`startbid`,`deposit`) " + "VALUES (%u,7,883107,25,2,0,1234,500," UI64FMTD ",2345,3456,100,77)", + snapshotAuctionId, routeNow + HOUR); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("custody FAIL: authoritative-read seed commit returned false\n"); + pass = false; + } + + struct RouteExpectation + { + uint32 auctionId; + bool seller; + bool bid; + char const* label; + }; + RouteExpectation const routeExpectations[] = + { + { routeBase + 1, true, false, "player seller" }, + { routeBase + 2, false, true, "bid only" }, + { routeBase + 3, false, false, "resolution only" }, + { routeBase + 4, false, false, "bot marker only" }, + { routeBase + 5, false, true, "bot marker plus bid" }, + { routeBase + 6, false, false, "bot marker plus canonical item" }, + }; + for (size_t i = 0; i < sizeof(routeExpectations) / sizeof(routeExpectations[0]); ++i) + { + RouteExpectation const& expected = routeExpectations[i]; + CustodyRouteState const route = CustodyLedger::GetRouteState(expected.auctionId); + if (route.usesPlayerSellerCustody != expected.seller || + route.hasLiveBidCustody != expected.bid) + { + printf("custody FAIL: route %s expected seller=%u bid=%u got seller=%u bid=%u\n", + expected.label, uint32(expected.seller), uint32(expected.bid), + uint32(route.usesPlayerSellerCustody), uint32(route.hasLiveBidCustody)); + pass = false; + } + } + + AuctionHouseObject* authoritativeMap = + sAuctionMgr.GetAuctionsMap(AUCTION_HOUSE_NEUTRAL); + authoritativeMap->RemoveAuction(snapshotAuctionId); + if (authoritativeMap->GetAuction(snapshotAuctionId)) + { + printf("custody FAIL: snapshot fixture unexpectedly exists in local AH map\n"); + pass = false; + } + + std::vector snapshot; + CustodyLedger::LoadReconcileSnapshot(snapshot); + bool sawSnapshotAuction = false; + bool sawSnapshotOrphan = false; + for (size_t i = 0; i < snapshot.size(); ++i) + { + CustodySnapshotGroup const& group = snapshot[i]; + if (group.auctionId == snapshotAuctionId) + { + sawSnapshotAuction = true; + if (!group.auction.exists || group.auction.auctionId != snapshotAuctionId || + group.auction.itemGuid != 883107 || group.auction.ownerGuid != 1234 || + group.auction.bidderGuid != 2345 || group.auction.bid != 3456 || + group.auction.deposit != 77 || group.rows.size() != 1) + { + printf("custody FAIL: joined live auction facts do not match DB fixture\n"); + pass = false; + } + } + else if (group.auctionId == orphanAuctionId) + { + sawSnapshotOrphan = true; + if (group.auction.exists || group.rows.size() != 1 || + group.rows[0].idemKey != "test:snapshot:orphan") + { + printf("custody FAIL: joined orphan facts do not match DB fixture\n"); + pass = false; + } + } + } + if (!sawSnapshotAuction || !sawSnapshotOrphan) + { + printf("custody FAIL: authoritative snapshot omitted live=%u orphan=%u\n", + uint32(sawSnapshotAuction), uint32(sawSnapshotOrphan)); + pass = false; + } + + if (!CustodyLedger::AuctionExists(snapshotAuctionId)) + { + printf("custody FAIL: AuctionExists did not see shared auction row\n"); + pass = false; + } + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", snapshotAuctionId); + if (CustodyLedger::AuctionExists(snapshotAuctionId)) + { + printf("custody FAIL: AuctionExists retained deleted shared auction row\n"); + pass = false; + } + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id` BETWEEN %u AND %u", + routeBase + 1, orphanAuctionId); + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", snapshotAuctionId); + // ================================================================ reconcile // Task 13: ReconcileScan flags custody drift and DeleteTerminalOlderThan // prunes only old terminal rows. From dd5cc2b836574ab87295456621a6ec64649ac3e8 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 12:22:07 +0100 Subject: [PATCH 03/18] fix(ah): reconcile custody from an indexed DB snapshot --- .../AuctionHouseBot/CustodyReconciler.cpp | 409 ++++++++++++++ src/game/AuctionHouseBot/CustodyReconciler.h | 92 +++ src/game/AuctionHouseBot/CustodyService.cpp | 224 +------- src/game/AuctionHouseBot/CustodyService.h | 6 +- src/mangosd/MangosdTest.cpp | 528 +++++++++++++++--- 5 files changed, 977 insertions(+), 282 deletions(-) create mode 100644 src/game/AuctionHouseBot/CustodyReconciler.cpp create mode 100644 src/game/AuctionHouseBot/CustodyReconciler.h diff --git a/src/game/AuctionHouseBot/CustodyReconciler.cpp b/src/game/AuctionHouseBot/CustodyReconciler.cpp new file mode 100644 index 000000000..62a0c5589 --- /dev/null +++ b/src/game/AuctionHouseBot/CustodyReconciler.cpp @@ -0,0 +1,409 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + */ + +#include "CustodyReconciler.h" + +#include +#include +#include + +namespace +{ + uint64 const CUSTODY_RECONCILE_MIN_ROW_AGE = 60; + + bool IsMarkerKey(std::string const& key) + { + return key.compare(0, 8, "botlist:") == 0; + } + + bool IsMature(CustodyRow const& row, uint64 now) + { + return row.createdTime == 0 || + (row.createdTime <= now && + now - row.createdTime >= CUSTODY_RECONCILE_MIN_ROW_AGE); + } + + CustodyRow ExpectedRow(std::string const& key, uint8 kind, uint8 role, + uint32 ownerGuid, uint32 amount, uint32 itemGuid, + uint32 auctionId) + { + CustodyRow row = {}; + row.idemKey = key; + row.kind = kind; + row.role = role; + row.state = CST_RESERVED; + row.ownerGuid = ownerGuid; + row.amount = amount; + row.itemGuid = itemGuid; + row.auctionId = auctionId; + return row; + } + + bool MatchesExpected(CustodyRow const& row, uint8 kind, uint8 role, + uint32 ownerGuid, uint32 amount, uint32 itemGuid, + uint32 auctionId) + { + return row.kind == kind && + row.role == role && + row.state == CST_RESERVED && + row.ownerGuid == ownerGuid && + row.amount == amount && + row.itemGuid == itemGuid && + row.auctionId == auctionId; + } + + void AddFinding(CustodyReconcileReport& report, CustodyRow const& row, + CustodyFindingReason reason, + CustodyRepairOwnership ownership, + CustodyFindingState state) + { + CustodyFinding finding; + finding.row = row; + finding.reason = reason; + finding.repairOwnership = ownership; + finding.state = state; + report.findings.push_back(finding); + + if (ownership == CUSTODY_REPAIR_BOT_SWEEP) + { + ++report.sweepOwnedCount; + } + else if (state == CUSTODY_FINDING_PENDING) + { + ++report.pendingBidCount; + } + else + { + ++report.confirmedDriftCount; + } + } + + bool BidTupleLess(CustodyRow const* lhs, CustodyRow const* rhs) + { + if (lhs->id != rhs->id) + { + return lhs->id < rhs->id; + } + if (lhs->idemKey != rhs->idemKey) + { + return lhs->idemKey < rhs->idemKey; + } + if (lhs->ownerGuid != rhs->ownerGuid) + { + return lhs->ownerGuid < rhs->ownerGuid; + } + return lhs->amount < rhs->amount; + } + + std::string BidFingerprint(CustodyAuctionFacts const& auction, + std::vector bidRows) + { + std::sort(bidRows.begin(), bidRows.end(), BidTupleLess); + std::ostringstream out; + out << uint32(auction.exists) << '|' + << auction.auctionId << '|' + << auction.itemGuid << '|' + << auction.ownerGuid << '|' + << auction.bidderGuid << '|' + << auction.bid << '|' + << auction.deposit; + for (size_t i = 0; i < bidRows.size(); ++i) + { + CustodyRow const& row = *bidRows[i]; + out << ';' << row.id << ',' << row.idemKey << ',' + << row.ownerGuid << ',' << row.amount; + } + return out.str(); + } + + bool FindingLess(CustodyFinding const& lhs, CustodyFinding const& rhs) + { + if (lhs.row.auctionId != rhs.row.auctionId) + { + return lhs.row.auctionId < rhs.row.auctionId; + } + if (lhs.row.idemKey != rhs.row.idemKey) + { + return lhs.row.idemKey < rhs.row.idemKey; + } + if (lhs.reason != rhs.reason) + { + return lhs.reason < rhs.reason; + } + return lhs.row.id < rhs.row.id; + } +} + +void CustodyReconciler::Scan(std::vector const& groups, + uint64 now, CustodyScanContext context, + CustodyReconcileReport& report) +{ + report.findings.clear(); + report.confirmedDriftCount = 0; + report.pendingBidCount = 0; + report.sweepOwnedCount = 0; + report.rowVisits = 0; + + std::unordered_set observedBidMismatches; + for (size_t groupIndex = 0; groupIndex < groups.size(); ++groupIndex) + { + CustodySnapshotGroup const& group = groups[groupIndex]; + std::vector markers; + std::vector nonMarkers; + std::vector itemRows; + std::vector depositRows; + std::vector extraSellerRows; + std::vector bidRows; + bool hasSellerCandidate = false; + bool allRowsMature = true; + std::string const itemKey = "item:" + std::to_string(group.auctionId); + std::string const depositKey = "dep:" + std::to_string(group.auctionId); + + for (size_t rowIndex = 0; rowIndex < group.rows.size(); ++rowIndex) + { + CustodyRow const& row = group.rows[rowIndex]; + ++report.rowVisits; + if (!IsMature(row, now)) + { + allRowsMature = false; + } + + bool const marker = IsMarkerKey(row.idemKey); + if (marker) + { + markers.push_back(&row); + } + else + { + nonMarkers.push_back(&row); + bool const canonicalItem = row.idemKey == itemKey; + bool const canonicalDeposit = row.idemKey == depositKey; + bool const sellerRole = row.role == ROLE_ITEM || + row.role == ROLE_DEPOSIT; + if (canonicalItem) + { + itemRows.push_back(&row); + } + if (canonicalDeposit) + { + depositRows.push_back(&row); + } + if (sellerRole && !canonicalItem && !canonicalDeposit) + { + extraSellerRows.push_back(&row); + } + hasSellerCandidate = hasSellerCandidate || canonicalItem || + canonicalDeposit || sellerRole; + } + + if (row.role == ROLE_BID) + { + bidRows.push_back(&row); + } + } + + if (!allRowsMature) + { + m_pendingBidMismatches.erase(group.auctionId); + continue; + } + + if (!group.auction.exists) + { + m_pendingBidMismatches.erase(group.auctionId); + for (size_t i = 0; i < markers.size(); ++i) + { + AddFinding(report, *markers[i], CUSTODY_FINDING_SWEEP_OWNED_MARKER, + CUSTODY_REPAIR_BOT_SWEEP, CUSTODY_FINDING_CONFIRMED); + } + for (size_t i = 0; i < nonMarkers.size(); ++i) + { + AddFinding(report, *nonMarkers[i], CUSTODY_FINDING_ORPHAN_PLAYER, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + continue; + } + + bool const markerOwned = !markers.empty(); + if (markerOwned) + { + if (markers.size() > 1) + { + CustodyRow duplicate = *markers[0]; + duplicate.id = 0; + AddFinding(report, duplicate, CUSTODY_FINDING_DUPLICATE_MARKER, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_CONFIRMED); + } + for (size_t i = 0; i < markers.size(); ++i) + { + CustodyRow const& row = *markers[i]; + if (!MatchesExpected(row, CUSTODY_ITEM, ROLE_RESOLUTION, + group.auction.ownerGuid, 0, group.auction.itemGuid, + group.auctionId)) + { + AddFinding(report, row, CUSTODY_FINDING_INVALID_MARKER, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_CONFIRMED); + } + } + } + else if (hasSellerCandidate) + { + if (itemRows.empty()) + { + AddFinding(report, ExpectedRow(itemKey, CUSTODY_ITEM, ROLE_ITEM, + group.auction.ownerGuid, 0, group.auction.itemGuid, + group.auctionId), CUSTODY_FINDING_MISSING, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + else if (itemRows.size() > 1) + { + for (size_t i = 0; i < itemRows.size(); ++i) + { + AddFinding(report, *itemRows[i], CUSTODY_FINDING_DUPLICATE, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + } + else if (!MatchesExpected(*itemRows[0], CUSTODY_ITEM, ROLE_ITEM, + group.auction.ownerGuid, 0, group.auction.itemGuid, + group.auctionId)) + { + AddFinding(report, *itemRows[0], CUSTODY_FINDING_MISMATCHED, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + + if (depositRows.empty()) + { + AddFinding(report, ExpectedRow(depositKey, CUSTODY_GOLD, + ROLE_DEPOSIT, group.auction.ownerGuid, + group.auction.deposit, 0, group.auctionId), + CUSTODY_FINDING_MISSING, CUSTODY_REPAIR_GENERIC, + CUSTODY_FINDING_CONFIRMED); + } + else if (depositRows.size() > 1) + { + for (size_t i = 0; i < depositRows.size(); ++i) + { + AddFinding(report, *depositRows[i], CUSTODY_FINDING_DUPLICATE, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + } + else if (!MatchesExpected(*depositRows[0], CUSTODY_GOLD, + ROLE_DEPOSIT, group.auction.ownerGuid, + group.auction.deposit, 0, group.auctionId)) + { + AddFinding(report, *depositRows[0], CUSTODY_FINDING_MISMATCHED, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + + for (size_t i = 0; i < extraSellerRows.size(); ++i) + { + AddFinding(report, *extraSellerRows[i], CUSTODY_FINDING_UNEXPECTED, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED); + } + } + + std::vector bidFindings; + bool const requiresBid = (markerOwned || hasSellerCandidate) && + group.auction.bidderGuid != 0; + if (bidRows.empty()) + { + if (requiresBid) + { + CustodyFinding finding; + finding.row = ExpectedRow("bid:" + std::to_string(group.auctionId) + + ":missing", CUSTODY_GOLD, ROLE_BID, + group.auction.bidderGuid, group.auction.bid, 0, + group.auctionId); + finding.reason = CUSTODY_FINDING_MISSING; + bidFindings.push_back(finding); + } + } + else if (group.auction.bidderGuid == 0) + { + for (size_t i = 0; i < bidRows.size(); ++i) + { + CustodyFinding finding; + finding.row = *bidRows[i]; + finding.reason = CUSTODY_FINDING_UNEXPECTED; + bidFindings.push_back(finding); + } + } + else if (bidRows.size() > 1) + { + for (size_t i = 0; i < bidRows.size(); ++i) + { + CustodyFinding finding; + finding.row = *bidRows[i]; + finding.reason = CUSTODY_FINDING_DUPLICATE; + bidFindings.push_back(finding); + } + } + else if (!MatchesExpected(*bidRows[0], CUSTODY_GOLD, ROLE_BID, + group.auction.bidderGuid, group.auction.bid, 0, + group.auctionId)) + { + CustodyFinding finding; + finding.row = *bidRows[0]; + finding.reason = CUSTODY_FINDING_MISMATCHED; + bidFindings.push_back(finding); + } + + if (bidFindings.empty()) + { + m_pendingBidMismatches.erase(group.auctionId); + continue; + } + + observedBidMismatches.insert(group.auctionId); + std::string const fingerprint = BidFingerprint(group.auction, bidRows); + std::unordered_map::iterator pending = + m_pendingBidMismatches.find(group.auctionId); + if (pending == m_pendingBidMismatches.end() || + pending->second.fingerprint != fingerprint) + { + PendingBidMismatch observation; + observation.fingerprint = fingerprint; + observation.firstSeen = now; + m_pendingBidMismatches[group.auctionId] = observation; + pending = m_pendingBidMismatches.find(group.auctionId); + } + + bool const confirmed = context == CUSTODY_SCAN_RUNTIME && + pending->second.firstSeen <= now && + now - pending->second.firstSeen >= CUSTODY_RECONCILE_MIN_ROW_AGE; + CustodyFindingState const state = confirmed + ? CUSTODY_FINDING_CONFIRMED : CUSTODY_FINDING_PENDING; + for (size_t i = 0; i < bidFindings.size(); ++i) + { + AddFinding(report, bidFindings[i].row, bidFindings[i].reason, + CUSTODY_REPAIR_MANUAL_ONLY, state); + } + } + + for (std::unordered_map::iterator itr = + m_pendingBidMismatches.begin(); + itr != m_pendingBidMismatches.end();) + { + if (observedBidMismatches.find(itr->first) == observedBidMismatches.end()) + { + itr = m_pendingBidMismatches.erase(itr); + } + else + { + ++itr; + } + } + + std::sort(report.findings.begin(), report.findings.end(), FindingLess); +} + +void CustodyReconciler::Reset() +{ + m_pendingBidMismatches.clear(); +} diff --git a/src/game/AuctionHouseBot/CustodyReconciler.h b/src/game/AuctionHouseBot/CustodyReconciler.h new file mode 100644 index 000000000..a0c409c2b --- /dev/null +++ b/src/game/AuctionHouseBot/CustodyReconciler.h @@ -0,0 +1,92 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + */ + +#ifndef MANGOS_CUSTODY_RECONCILER_H +#define MANGOS_CUSTODY_RECONCILER_H + +#include "CustodyLedger.h" + +#include +#include +#include + +enum CustodyFindingReason +{ + CUSTODY_FINDING_MISSING, + CUSTODY_FINDING_DUPLICATE, + CUSTODY_FINDING_MISMATCHED, + CUSTODY_FINDING_UNEXPECTED, + CUSTODY_FINDING_ORPHAN_PLAYER, + CUSTODY_FINDING_INVALID_MARKER, + CUSTODY_FINDING_DUPLICATE_MARKER, + CUSTODY_FINDING_SWEEP_OWNED_MARKER, +}; + +enum CustodyRepairOwnership +{ + CUSTODY_REPAIR_GENERIC, + CUSTODY_REPAIR_MANUAL_ONLY, + CUSTODY_REPAIR_BOT_SWEEP, +}; + +enum CustodyFindingState +{ + CUSTODY_FINDING_CONFIRMED, + CUSTODY_FINDING_PENDING, +}; + +enum CustodyScanContext +{ + CUSTODY_SCAN_BOOT, + CUSTODY_SCAN_RUNTIME, +}; + +struct CustodyFinding +{ + CustodyRow row; + CustodyFindingReason reason; + CustodyRepairOwnership repairOwnership; + CustodyFindingState state; +}; + +struct CustodyReconcileReport +{ + std::vector findings; + uint32 confirmedDriftCount; + uint32 pendingBidCount; + uint32 sweepOwnedCount; + uint64 rowVisits; +}; + +struct CustodyMaintenancePlan +{ + bool reconcile; + bool prune; + bool sweepBotMaterializations; +}; + +class CustodyReconciler +{ + public: + void Scan(std::vector const& groups, uint64 now, + CustodyScanContext context, CustodyReconcileReport& report); + + void Reset(); + + private: + struct PendingBidMismatch + { + std::string fingerprint; + uint64 firstSeen; + }; + + std::unordered_map m_pendingBidMismatches; +}; + +#endif // MANGOS_CUSTODY_RECONCILER_H diff --git a/src/game/AuctionHouseBot/CustodyService.cpp b/src/game/AuctionHouseBot/CustodyService.cpp index 195d9c50c..e080e32e7 100644 --- a/src/game/AuctionHouseBot/CustodyService.cpp +++ b/src/game/AuctionHouseBot/CustodyService.cpp @@ -27,7 +27,6 @@ #include "Config/Config.h" #include "CustodyLedger.h" -#include "AuctionHouseMgr.h" #include "Log.h" #include "Mail.h" #include "Player.h" @@ -46,180 +45,7 @@ namespace { - uint64 const CUSTODY_RECONCILE_MIN_ROW_AGE = 60; // seconds - - bool IsMature(CustodyRow const& row, uint64 now) - { - return row.createdTime == 0 || row.createdTime + CUSTODY_RECONCILE_MIN_ROW_AGE <= now; - } - - AuctionEntry* FindLiveAuction(uint32 auctionId) - { - for (uint32 i = 0; i < MAX_AUCTION_HOUSE_TYPE; ++i) - { - AuctionHouseObject* auctions = sAuctionMgr.GetAuctionsMap(AuctionHouseType(i)); - AuctionEntry* auction = auctions->GetAuction(auctionId); - if (auction) - { - return auction; - } - } - - return NULL; - } - - bool HasMatureCustodyRow(std::vector const& rows, uint32 auctionId, uint64 now) - { - for (size_t i = 0; i < rows.size(); ++i) - { - if (rows[i].auctionId == auctionId && IsMature(rows[i], now)) - { - return true; - } - } - - return false; - } - - CustodyRow const* FindRowByKey(std::vector const& rows, - std::string const& key) - { - for (size_t i = 0; i < rows.size(); ++i) - { - if (rows[i].idemKey == key) - { - return &rows[i]; - } - } - - return NULL; - } - - void CollectLiveBidRows(std::vector const& rows, uint32 auctionId, - std::vector& out) - { - for (size_t i = 0; i < rows.size(); ++i) - { - CustodyRow const& row = rows[i]; - if (row.auctionId == auctionId && - row.kind == CUSTODY_GOLD && - row.role == ROLE_BID && - row.state == CST_RESERVED) - { - out.push_back(&row); - } - } - } - - CustodyRow ExpectedRow(std::string const& key, uint8 kind, uint8 role, - uint32 ownerGuid, uint32 amount, uint32 itemGuid, - uint32 auctionId) - { - CustodyRow row; - row.id = 0; - row.idemKey = key; - row.kind = kind; - row.role = role; - row.state = CST_RESERVED; - row.ownerGuid = ownerGuid; - row.beneficiaryGuid = 0; - row.amount = amount; - row.itemGuid = itemGuid; - row.auctionId = auctionId; - row.createdTime = 0; - row.resolvedTime = 0; - return row; - } - - void AddDrift(std::vector& out, CustodyRow const& row, - char const* reason) - { - out.push_back(row); - sLog.outError("custody drift: %s key=%s auction=%u kind=%u role=%u state=%u", - reason, row.idemKey.c_str(), row.auctionId, - uint32(row.kind), uint32(row.role), uint32(row.state)); - } - - bool MatchesExpected(CustodyRow const& row, uint8 kind, uint8 role, - uint32 ownerGuid, uint32 amount, uint32 itemGuid, - uint32 auctionId) - { - return row.kind == kind && - row.role == role && - row.state == CST_RESERVED && - row.ownerGuid == ownerGuid && - row.amount == amount && - row.itemGuid == itemGuid && - row.auctionId == auctionId; - } - - void CheckExpectedKey(std::vector const& rows, - std::vector& drift, - std::string const& key, - uint8 kind, uint8 role, uint32 ownerGuid, - uint32 amount, uint32 itemGuid, uint32 auctionId, - char const* reason) - { - CustodyRow const* row = FindRowByKey(rows, key); - if (!row) - { - AddDrift(drift, ExpectedRow(key, kind, role, ownerGuid, amount, itemGuid, auctionId), reason); - return; - } - - if (!MatchesExpected(*row, kind, role, ownerGuid, amount, itemGuid, auctionId)) - { - AddDrift(drift, *row, reason); - } - } - - void CheckAuctionExpectedRows(AuctionEntry const* auction, - std::vector const& rows, - std::vector& drift) - { - std::string auctionId = std::to_string(auction->Id); - - CheckExpectedKey(rows, drift, "item:" + auctionId, CUSTODY_ITEM, ROLE_ITEM, - auction->owner, 0, auction->itemGuidLow, auction->Id, - "missing or invalid item row"); - - CheckExpectedKey(rows, drift, "dep:" + auctionId, CUSTODY_GOLD, ROLE_DEPOSIT, - auction->owner, auction->deposit, 0, auction->Id, - "missing or invalid deposit row"); - - std::vector liveBidRows; - CollectLiveBidRows(rows, auction->Id, liveBidRows); - if (auction->bidder != 0) - { - if (liveBidRows.empty()) - { - AddDrift(drift, ExpectedRow("bid:" + auctionId + ":missing", - CUSTODY_GOLD, ROLE_BID, - auction->bidder, auction->bid, 0, - auction->Id), - "missing live bid row"); - } - else if (liveBidRows.size() > 1) - { - for (size_t i = 0; i < liveBidRows.size(); ++i) - { - AddDrift(drift, *liveBidRows[i], "ambiguous live bid row"); - } - } - else if (liveBidRows[0]->ownerGuid != auction->bidder || - liveBidRows[0]->amount != auction->bid) - { - AddDrift(drift, *liveBidRows[0], "invalid live bid row"); - } - } - else - { - for (size_t i = 0; i < liveBidRows.size(); ++i) - { - AddDrift(drift, *liveBidRows[i], "unexpected live bid row"); - } - } - } + CustodyReconciler s_reconciler; } void CustodyService::ReserveGold(CustodyDeferred& d, uint32 ownerGuid, @@ -431,41 +257,27 @@ bool CustodyService::CommitCheckedOrForcedFail(std::string const& phase) return CharacterDatabase.CommitTransactionChecked(); } -void CustodyService::ReconcileScan(bool dryRun, std::vector& orphans) +void CustodyService::ReconcileScan(uint64 now, CustodyScanContext context, + CustodyReconcileReport& report) { - (void)dryRun; - - uint64 const now = static_cast(time(NULL)); - std::vector nonTerminal; - CustodyLedger::LoadNonTerminal(nonTerminal); - - for (size_t i = 0; i < nonTerminal.size(); ++i) - { - CustodyRow const& row = nonTerminal[i]; - if (!IsMature(row, now)) - { - continue; - } - - if (!FindLiveAuction(row.auctionId)) - { - AddDrift(orphans, row, "orphan row"); - } - } + std::vector snapshot; + CustodyLedger::LoadReconcileSnapshot(snapshot); + s_reconciler.Scan(snapshot, now, context, report); +} - for (uint32 i = 0; i < MAX_AUCTION_HOUSE_TYPE; ++i) +void CustodyService::ReconcileScan(bool legacyDryRun, + std::vector& legacyDrift) +{ + CustodyReconcileReport report; + ReconcileScan(static_cast(time(NULL)), + legacyDryRun ? CUSTODY_SCAN_RUNTIME : CUSTODY_SCAN_BOOT, report); + for (size_t i = 0; i < report.findings.size(); ++i) { - AuctionHouseObject* auctions = sAuctionMgr.GetAuctionsMap(AuctionHouseType(i)); - AuctionHouseObject::AuctionEntryMap const& map = auctions->GetAuctions(); - for (AuctionHouseObject::AuctionEntryMap::const_iterator itr = map.begin(); itr != map.end(); ++itr) + CustodyFinding const& finding = report.findings[i]; + if (finding.state == CUSTODY_FINDING_CONFIRMED && + finding.repairOwnership == CUSTODY_REPAIR_GENERIC) { - AuctionEntry const* auction = itr->second; - if (!HasMatureCustodyRow(nonTerminal, auction->Id, now)) - { - continue; - } - - CheckAuctionExpectedRows(auction, nonTerminal, orphans); + legacyDrift.push_back(finding.row); } } } diff --git a/src/game/AuctionHouseBot/CustodyService.h b/src/game/AuctionHouseBot/CustodyService.h index 6d84fea89..04028c138 100644 --- a/src/game/AuctionHouseBot/CustodyService.h +++ b/src/game/AuctionHouseBot/CustodyService.h @@ -41,6 +41,7 @@ #include "Platform/Define.h" #include "CustodyDeferred.h" #include "CustodyLedger.h" +#include "CustodyReconciler.h" #include #include @@ -253,7 +254,10 @@ namespace CustodyService * @param dryRun Audit-only flag; no mutations happen in either mode. * @param orphans Destination vector; drift rows are appended. */ - void ReconcileScan(bool dryRun, std::vector& orphans); + void ReconcileScan(uint64 now, CustodyScanContext context, + CustodyReconcileReport& report); + + void ReconcileScan(bool legacyDryRun, std::vector& legacyDrift); } #endif // MANGOS_CUSTODY_SERVICE_H diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index c3b615204..807b2ab3f 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -12,6 +12,7 @@ #include "AuctionHouseBot/AuctionIntentExecutor.h" #include "AuctionHouseBot/CustodyDeferred.h" #include "AuctionHouseBot/CustodyLedger.h" +#include "AuctionHouseBot/CustodyReconciler.h" #include "AuctionHouseBot/CustodyService.h" #include "AuctionIntents.h" #include "WorkerSupervisor.h" @@ -51,6 +52,422 @@ static std::string TestHexEncode(ByteBuffer const& bb) return out; } +static CustodyRow TestCustodyRow(uint32 id, std::string const& key, + uint8 kind, uint8 role, uint32 ownerGuid, + uint32 amount, uint32 itemGuid, + uint32 auctionId, uint64 createdTime = 0) +{ + CustodyRow row = {}; + row.id = id; + row.idemKey = key; + row.kind = kind; + row.role = role; + row.state = CST_RESERVED; + row.ownerGuid = ownerGuid; + row.amount = amount; + row.itemGuid = itemGuid; + row.auctionId = auctionId; + row.createdTime = createdTime; + return row; +} + +static CustodySnapshotGroup TestCustodyGroup( + uint32 auctionId, uint32 itemGuid, uint32 ownerGuid, + uint32 bidderGuid, uint32 bid, uint32 deposit, + std::vector const& rows) +{ + CustodySnapshotGroup group = {}; + group.auctionId = auctionId; + group.auction.exists = true; + group.auction.auctionId = auctionId; + group.auction.itemGuid = itemGuid; + group.auction.ownerGuid = ownerGuid; + group.auction.bidderGuid = bidderGuid; + group.auction.bid = bid; + group.auction.deposit = deposit; + group.rows = rows; + return group; +} + +static uint32 CountCustodyFindings(CustodyReconcileReport const& report, + CustodyFindingReason reason, + CustodyRepairOwnership ownership, + CustodyFindingState state) +{ + uint32 count = 0; + for (size_t i = 0; i < report.findings.size(); ++i) + { + CustodyFinding const& finding = report.findings[i]; + if (finding.reason == reason && + finding.repairOwnership == ownership && + finding.state == state) + { + ++count; + } + } + return count; +} + +static bool RunPureCustodyReconcilerTests() +{ + bool pass = true; + uint32 const botOwner = AHBOT_SYSTEM_OWNER_GUID; + + auto marker = [botOwner](uint32 auctionId, uint32 itemGuid, + uint64 createdTime = 0) -> CustodyRow + { + return TestCustodyRow(auctionId, "botlist:test:" + std::to_string(auctionId), + CUSTODY_ITEM, ROLE_RESOLUTION, botOwner, 0, itemGuid, + auctionId, createdTime); + }; + auto bidRow = [](uint32 id, uint32 auctionId, uint32 bidder, + uint32 amount, uint64 createdTime = 0) -> CustodyRow + { + return TestCustodyRow(id, "bid:" + std::to_string(auctionId) + + ":" + std::to_string(id), CUSTODY_GOLD, ROLE_BID, + bidder, amount, 0, auctionId, createdTime); + }; + + // Marker-owned listings do not invent player seller custody. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector groups; + groups.push_back(TestCustodyGroup(980001, 880001, botOwner, 0, 0, 0, + std::vector(1, marker(980001, 880001)))); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty() || report.rowVisits != 1) + { + printf("custody FAIL: valid DB-only bot marker produced drift\n"); + pass = false; + } + } + + // Marker plus matching player bid stays clean. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector rows; + rows.push_back(marker(980002, 880002)); + rows.push_back(bidRow(2, 980002, 2202, 202)); + std::vector groups(1, + TestCustodyGroup(980002, 880002, botOwner, 2202, 202, 0, rows)); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: matching bot-listing bid produced drift\n"); + pass = false; + } + } + + // A fresh row defers the whole group before and after auction facts change. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector rows; + rows.push_back(marker(980003, 880003, 900)); + rows.push_back(bidRow(3, 980003, 2203, 203, 950)); + std::vector groups(1, + TestCustodyGroup(980003, 880003, botOwner, 0, 0, 0, rows)); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty() || report.pendingBidCount != 0) + { + printf("custody FAIL: fresh bid was not deferred before auction update\n"); + pass = false; + } + groups[0].auction.bidderGuid = 2203; + groups[0].auction.bid = 203; + reconciler.Scan(groups, 1005, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty() || report.pendingBidCount != 0) + { + printf("custody FAIL: fresh bid was not deferred after auction update\n"); + pass = false; + } + } + + // Complete and partial player seller custody. + { + std::vector rows; + rows.push_back(TestCustodyRow(1, "item:980004", CUSTODY_ITEM, + ROLE_ITEM, 1204, 0, 880004, 980004)); + rows.push_back(TestCustodyRow(2, "dep:980004", CUSTODY_GOLD, + ROLE_DEPOSIT, 1204, 44, 0, 980004)); + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector groups(1, + TestCustodyGroup(980004, 880004, 1204, 0, 0, 44, rows)); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: complete player seller custody produced drift\n"); + pass = false; + } + + groups[0].rows.pop_back(); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (CountCustodyFindings(report, CUSTODY_FINDING_MISSING, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED) != 1) + { + printf("custody FAIL: partial seller custody did not report one missing row\n"); + pass = false; + } + } + + // Resolution-only legacy provenance and a valid bid-only overlay stay clean. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector groups; + groups.push_back(TestCustodyGroup(980005, 880005, 1205, 0, 0, 55, + std::vector(1, TestCustodyRow(1, "resolve:test:980005", + CUSTODY_GOLD, ROLE_RESOLUTION, 0, 0, 0, 980005)))); + groups.push_back(TestCustodyGroup(980006, 880006, 1206, 2206, 206, 66, + std::vector(1, bidRow(1, 980006, 2206, 206)))); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: valid legacy provenance or bid overlay produced drift\n"); + pass = false; + } + } + + // A future canonical bot item remains marker-owned. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector rows; + rows.push_back(marker(980007, 880007)); + rows.push_back(TestCustodyRow(2, "item:980007", CUSTODY_ITEM, + ROLE_ITEM, botOwner, 0, 880007, 980007)); + std::vector groups(1, + TestCustodyGroup(980007, 880007, botOwner, 0, 0, 0, rows)); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: canonical bot item overrode marker provenance\n"); + pass = false; + } + } + + // Invalid and duplicate markers are confirmed manual-only findings. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + CustodyRow invalid = marker(980008, 880008); + invalid.role = ROLE_ITEM; + std::vector groups(1, + TestCustodyGroup(980008, 880008, botOwner, 0, 0, 0, + std::vector(1, invalid))); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (CountCustodyFindings(report, CUSTODY_FINDING_INVALID_MARKER, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_CONFIRMED) != 1) + { + printf("custody FAIL: invalid marker ownership/reason mismatch\n"); + pass = false; + } + + std::vector duplicateRows; + duplicateRows.push_back(marker(980009, 880009)); + CustodyRow second = marker(980009, 880009); + second.id += 1; + second.idemKey += ":duplicate"; + duplicateRows.push_back(second); + groups[0] = TestCustodyGroup(980009, 880009, botOwner, 0, 0, 0, + duplicateRows); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (CountCustodyFindings(report, CUSTODY_FINDING_DUPLICATE_MARKER, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_CONFIRMED) != 1) + { + printf("custody FAIL: duplicate markers did not produce one finding\n"); + pass = false; + } + } + + // Orphan marker cleanup belongs to the bot sweep; player rows remain generic. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + CustodySnapshotGroup orphan = {}; + orphan.auctionId = 980010; + orphan.rows.push_back(marker(980010, 880010)); + orphan.rows.push_back(bidRow(2, 980010, 2210, 210)); + std::vector groups(1, orphan); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (report.sweepOwnedCount != 1 || report.confirmedDriftCount != 1 || + CountCustodyFindings(report, CUSTODY_FINDING_SWEEP_OWNED_MARKER, + CUSTODY_REPAIR_BOT_SWEEP, CUSTODY_FINDING_CONFIRMED) != 1 || + CountCustodyFindings(report, CUSTODY_FINDING_ORPHAN_PLAYER, + CUSTODY_REPAIR_GENERIC, CUSTODY_FINDING_CONFIRMED) != 1) + { + printf("custody FAIL: orphan marker/player ownership split is wrong\n"); + pass = false; + } + } + + // Missing, duplicate, mismatched, and unexpected bids begin pending. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector groups; + groups.push_back(TestCustodyGroup(980011, 880011, botOwner, 2211, 211, 0, + std::vector(1, marker(980011, 880011)))); + std::vector duplicate; + duplicate.push_back(bidRow(1, 980012, 2212, 212)); + duplicate.push_back(bidRow(2, 980012, 2212, 212)); + groups.push_back(TestCustodyGroup(980012, 880012, 1212, 2212, 212, 0, + duplicate)); + std::vector mismatched; + mismatched.push_back(marker(980013, 880013)); + mismatched.push_back(bidRow(2, 980013, 9999, 213)); + groups.push_back(TestCustodyGroup(980013, 880013, botOwner, 2213, 213, 0, + mismatched)); + std::vector unexpected; + unexpected.push_back(marker(980014, 880014)); + unexpected.push_back(bidRow(2, 980014, 2214, 214)); + groups.push_back(TestCustodyGroup(980014, 880014, botOwner, 0, 0, 0, + unexpected)); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 5 || report.confirmedDriftCount != 0 || + CountCustodyFindings(report, CUSTODY_FINDING_MISSING, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_PENDING) != 1 || + CountCustodyFindings(report, CUSTODY_FINDING_DUPLICATE, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_PENDING) != 2 || + CountCustodyFindings(report, CUSTODY_FINDING_MISMATCHED, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_PENDING) != 1 || + CountCustodyFindings(report, CUSTODY_FINDING_UNEXPECTED, + CUSTODY_REPAIR_MANUAL_ONLY, CUSTODY_FINDING_PENDING) != 1) + { + printf("custody FAIL: bid mismatch reason or pending counts are wrong\n"); + pass = false; + } + } + + // Stable bid mismatch confirmation uses exact 1000/1059/1060 boundaries. + { + CustodyReconciler reconciler; + CustodyReconcileReport report; + std::vector groups(1, + TestCustodyGroup(980015, 880015, botOwner, 2215, 215, 0, + std::vector(1, marker(980015, 880015)))); + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: first bid mismatch was not pending\n"); + pass = false; + } + reconciler.Scan(groups, 1059, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: 59-second bid mismatch did not remain pending\n"); + pass = false; + } + reconciler.Scan(groups, 1060, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 0 || report.confirmedDriftCount != 1) + { + printf("custody FAIL: 60-second bid mismatch was not confirmed\n"); + pass = false; + } + + groups[0].rows.push_back(bidRow(2, 980015, 2215, 215)); + reconciler.Scan(groups, 1061, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: matching bid did not clear pending cache\n"); + pass = false; + } + } + + // Changed, fresh, removed, and boot observations cannot confirm old state. + { + CustodyReconcileReport report; + std::vector groups(1, + TestCustodyGroup(980016, 880016, botOwner, 2216, 216, 0, + std::vector(1, marker(980016, 880016)))); + + CustodyReconciler changed; + changed.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + groups[0].auction.bid = 217; + changed.Scan(groups, 1060, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: changed fingerprint confirmed stale mismatch\n"); + pass = false; + } + + CustodyReconciler fresh; + fresh.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + groups[0].rows.push_back(bidRow(2, 980016, 9999, 217, 1100)); + fresh.Scan(groups, 1060, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: fresh group did not clear mismatch observation\n"); + pass = false; + } + groups[0].rows.pop_back(); + fresh.Scan(groups, 1200, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: fresh-group clear retained old mismatch age\n"); + pass = false; + } + + CustodyReconciler removed; + removed.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + removed.Scan(std::vector(), 1060, + CUSTODY_SCAN_RUNTIME, report); + removed.Scan(groups, 1120, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: removed group retained old mismatch age\n"); + pass = false; + } + + CustodyReconciler boot; + boot.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + boot.Scan(groups, 1060, CUSTODY_SCAN_BOOT, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: boot scan confirmed prior mismatch\n"); + pass = false; + } + + CustodyReconciler future; + groups[0].rows.push_back(bidRow(3, 980016, 9999, 217, 2000)); + future.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (!report.findings.empty()) + { + printf("custody FAIL: future timestamp was treated as mature\n"); + pass = false; + } + } + + // A large clean population visits each input row exactly once. + { + std::vector groups; + groups.reserve(50000); + for (uint32 i = 0; i < 50000; ++i) + { + uint32 const auctionId = 1000000 + i; + groups.push_back(TestCustodyGroup(auctionId, 2000000 + i, + 3000000 + i, 0, 0, 0, + std::vector(1, TestCustodyRow(i + 1, + "resolve:linear:" + std::to_string(i), CUSTODY_GOLD, + ROLE_RESOLUTION, 0, 0, 0, auctionId)))); + } + CustodyReconciler reconciler; + CustodyReconcileReport report; + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + if (report.rowVisits != 50000 || !report.findings.empty()) + { + printf("custody FAIL: linear scan visits=" UI64FMTD " findings=%u\n", + report.rowVisits, uint32(report.findings.size())); + pass = false; + } + } + + return pass; +} + /// Self-test for Database::CommitTransactionChecked(): proves the runtime /// (async-enabled) path is synchronous, durable and returns the REAL result. /// Returns 0 on pass, non-zero on fail. @@ -535,11 +952,14 @@ static int RunCustodyTest() CharacterDatabase.DirectPExecute( "DELETE FROM `auction` WHERE `id`=%u", snapshotAuctionId); + if (!RunPureCustodyReconcilerTests()) + { + pass = false; + } + // ================================================================ reconcile // Task 13: ReconcileScan flags custody drift and DeleteTerminalOlderThan // prunes only old terminal rows. - static AuctionHouseEntry testHouse = { 7, 0, 0, 0 }; - AuctionHouseObject* testAuctions = sAuctionMgr.GetAuctionsMap(AUCTION_HOUSE_NEUTRAL); uint32 const liveAuctionId = 970002; uint32 const missingItemAuctionId = 970003; uint32 const duplicateBidAuctionId = 970006; @@ -552,59 +972,19 @@ static int RunCustodyTest() "DELETE FROM `custody_ledger` WHERE `idem_key` IN " "('item:970002','dep:970002','item:970003','dep:970003'," "'item:970006','dep:970006','bid:970006:1','bid:970006:2')"); - testAuctions->RemoveAuction(liveAuctionId); - testAuctions->RemoveAuction(missingItemAuctionId); - testAuctions->RemoveAuction(duplicateBidAuctionId); - - AuctionEntry* liveAuction = new AuctionEntry; - liveAuction->Id = liveAuctionId; - liveAuction->itemGuidLow = 880002; - liveAuction->itemTemplate = 25; - liveAuction->itemCount = 1; - liveAuction->itemRandomPropertyId = 0; - liveAuction->owner = 1001; - liveAuction->startbid = 10; - liveAuction->bid = 0; - liveAuction->buyout = 0; - liveAuction->expireTime = time(NULL) + HOUR; - liveAuction->bidder = 0; - liveAuction->deposit = 5; - liveAuction->auctionHouseEntry = &testHouse; - testAuctions->AddAuction(liveAuction); - - AuctionEntry* missingItemAuction = new AuctionEntry; - missingItemAuction->Id = missingItemAuctionId; - missingItemAuction->itemGuidLow = 880003; - missingItemAuction->itemTemplate = 25; - missingItemAuction->itemCount = 1; - missingItemAuction->itemRandomPropertyId = 0; - missingItemAuction->owner = 1002; - missingItemAuction->startbid = 10; - missingItemAuction->bid = 0; - missingItemAuction->buyout = 0; - missingItemAuction->expireTime = time(NULL) + HOUR; - missingItemAuction->bidder = 0; - missingItemAuction->deposit = 5; - missingItemAuction->auctionHouseEntry = &testHouse; - testAuctions->AddAuction(missingItemAuction); - - AuctionEntry* duplicateBidAuction = new AuctionEntry; - duplicateBidAuction->Id = duplicateBidAuctionId; - duplicateBidAuction->itemGuidLow = 880006; - duplicateBidAuction->itemTemplate = 25; - duplicateBidAuction->itemCount = 1; - duplicateBidAuction->itemRandomPropertyId = 0; - duplicateBidAuction->owner = 1006; - duplicateBidAuction->startbid = 10; - duplicateBidAuction->bid = 77; - duplicateBidAuction->buyout = 0; - duplicateBidAuction->expireTime = time(NULL) + HOUR; - duplicateBidAuction->bidder = 2006; - duplicateBidAuction->deposit = 5; - duplicateBidAuction->auctionHouseEntry = &testHouse; - testAuctions->AddAuction(duplicateBidAuction); + CharacterDatabase.DirectExecute( + "DELETE FROM `auction` WHERE `id` IN (970002,970003,970006)"); CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `auction` " + "(`id`,`houseid`,`itemguid`,`item_template`,`item_count`," + "`item_randompropertyid`,`itemowner`,`buyoutprice`,`time`,`buyguid`," + "`lastbid`,`startbid`,`deposit`) VALUES " + "(970002,7,880002,25,1,0,1001,0," UI64FMTD ",0,0,10,5)," + "(970003,7,880003,25,1,0,1002,0," UI64FMTD ",0,0,10,5)," + "(970006,7,880006,25,1,0,1006,0," UI64FMTD ",2006,77,10,5)", + now + HOUR, now + HOUR, now + HOUR); CharacterDatabase.PExecute( "INSERT INTO `custody_ledger` " "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," @@ -672,34 +1052,35 @@ static int RunCustodyTest() } { - std::vector drift; - CustodyService::ReconcileScan(true, drift); + CustodyReconcileReport report; + CustodyService::ReconcileScan(now, CUSTODY_SCAN_RUNTIME, report); bool sawOrphan = false; bool sawCleanLive = false; bool sawMissingItem = false; - bool sawDuplicateBid1 = false; - bool sawDuplicateBid2 = false; - for (size_t i = 0; i < drift.size(); ++i) + uint32 pendingDuplicateBids = 0; + for (size_t i = 0; i < report.findings.size(); ++i) { - if (drift[i].idemKey == "test:recon:orphan") + CustodyFinding const& finding = report.findings[i]; + if (finding.row.idemKey == "test:recon:orphan" && + finding.reason == CUSTODY_FINDING_ORPHAN_PLAYER && + finding.repairOwnership == CUSTODY_REPAIR_GENERIC) { sawOrphan = true; } - if (drift[i].auctionId == liveAuctionId) + if (finding.row.auctionId == liveAuctionId) { sawCleanLive = true; } - if (drift[i].idemKey == "item:970003") + if (finding.row.idemKey == "item:970003" && + finding.reason == CUSTODY_FINDING_MISSING) { sawMissingItem = true; } - if (drift[i].idemKey == "bid:970006:1" && drift[i].id != 0) - { - sawDuplicateBid1 = true; - } - if (drift[i].idemKey == "bid:970006:2" && drift[i].id != 0) + if (finding.row.auctionId == duplicateBidAuctionId && + finding.reason == CUSTODY_FINDING_DUPLICATE && + finding.state == CUSTODY_FINDING_PENDING) { - sawDuplicateBid2 = true; + ++pendingDuplicateBids; } } if (!sawOrphan) @@ -717,9 +1098,10 @@ static int RunCustodyTest() printf("custody FAIL: reconcile did not flag live auction missing item row\n"); pass = false; } - if (!sawDuplicateBid1 || !sawDuplicateBid2) + if (pendingDuplicateBids != 2) { - printf("custody FAIL: reconcile did not surface duplicate live bid rows\n"); + printf("custody FAIL: reconcile duplicate bid pending count expected 2 got %u\n", + pendingDuplicateBids); pass = false; } } @@ -746,13 +1128,9 @@ static int RunCustodyTest() } } - testAuctions->RemoveAuction(liveAuctionId); - testAuctions->RemoveAuction(missingItemAuctionId); - testAuctions->RemoveAuction(duplicateBidAuctionId); - delete liveAuction; - delete missingItemAuction; - delete duplicateBidAuction; CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "DELETE FROM `auction` WHERE `id` IN (970002,970003,970006)"); CharacterDatabase.PExecute( "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:recon:%%'"); CharacterDatabase.PExecute( From 3de93f55ba940418a4a0d593cee4dcc5bed524a9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 12:26:55 +0100 Subject: [PATCH 04/18] fix(ah): gate and cap custody maintenance --- .../AuctionHouseBot/CustodyReconciler.cpp | 57 ++++++++++++ src/game/AuctionHouseBot/CustodyReconciler.h | 19 ++++ src/game/AuctionHouseBot/CustodyService.cpp | 41 ++++++++ src/game/AuctionHouseBot/CustodyService.h | 6 ++ src/game/Object/AuctionHouseMgr.cpp | 12 ++- src/game/WorldHandlers/World.cpp | 17 ++-- src/mangosd/MangosdTest.cpp | 93 +++++++++++++++++++ 7 files changed, 233 insertions(+), 12 deletions(-) diff --git a/src/game/AuctionHouseBot/CustodyReconciler.cpp b/src/game/AuctionHouseBot/CustodyReconciler.cpp index 62a0c5589..652d7764d 100644 --- a/src/game/AuctionHouseBot/CustodyReconciler.cpp +++ b/src/game/AuctionHouseBot/CustodyReconciler.cpp @@ -140,6 +140,63 @@ namespace } } +CustodyDetailBudget::CustodyDetailBudget(uint32 cap) + : m_cap(cap), m_allowed(0), m_suppressed(0) +{ +} + +bool CustodyDetailBudget::Take() +{ + if (m_allowed < m_cap) + { + ++m_allowed; + return true; + } + + ++m_suppressed; + return false; +} + +char const* CustodyFindingReasonName(CustodyFindingReason reason) +{ + switch (reason) + { + case CUSTODY_FINDING_MISSING: return "missing"; + case CUSTODY_FINDING_DUPLICATE: return "duplicate"; + case CUSTODY_FINDING_MISMATCHED: return "mismatched"; + case CUSTODY_FINDING_UNEXPECTED: return "unexpected"; + case CUSTODY_FINDING_ORPHAN_PLAYER: return "orphan-player"; + case CUSTODY_FINDING_INVALID_MARKER: return "invalid-marker"; + case CUSTODY_FINDING_DUPLICATE_MARKER: return "duplicate-marker"; + case CUSTODY_FINDING_SWEEP_OWNED_MARKER: return "sweep-owned-marker"; + } + + return "unknown"; +} + +char const* CustodyRepairOwnershipName(CustodyRepairOwnership ownership) +{ + switch (ownership) + { + case CUSTODY_REPAIR_GENERIC: return "generic"; + case CUSTODY_REPAIR_MANUAL_ONLY: return "manual-only"; + case CUSTODY_REPAIR_BOT_SWEEP: return "bot-sweep"; + } + + return "unknown"; +} + +char const* CustodyFindingStateName(CustodyFindingState state) +{ + switch (state) + { + case CUSTODY_FINDING_CONFIRMED: return "confirmed"; + case CUSTODY_FINDING_PENDING: return "pending"; + } + + return "unknown"; +} + void CustodyReconciler::Scan(std::vector const& groups, uint64 now, CustodyScanContext context, CustodyReconcileReport& report) diff --git a/src/game/AuctionHouseBot/CustodyReconciler.h b/src/game/AuctionHouseBot/CustodyReconciler.h index a0c409c2b..1d05cd954 100644 --- a/src/game/AuctionHouseBot/CustodyReconciler.h +++ b/src/game/AuctionHouseBot/CustodyReconciler.h @@ -71,6 +71,25 @@ struct CustodyMaintenancePlan bool sweepBotMaterializations; }; +class CustodyDetailBudget +{ + public: + explicit CustodyDetailBudget(uint32 cap); + + bool Take(); + uint32 Allowed() const { return m_allowed; } + uint32 Suppressed() const { return m_suppressed; } + + private: + uint32 m_cap; + uint32 m_allowed; + uint32 m_suppressed; +}; + +char const* CustodyFindingReasonName(CustodyFindingReason reason); +char const* CustodyRepairOwnershipName(CustodyRepairOwnership ownership); +char const* CustodyFindingStateName(CustodyFindingState state); + class CustodyReconciler { public: diff --git a/src/game/AuctionHouseBot/CustodyService.cpp b/src/game/AuctionHouseBot/CustodyService.cpp index e080e32e7..e94bd5abf 100644 --- a/src/game/AuctionHouseBot/CustodyService.cpp +++ b/src/game/AuctionHouseBot/CustodyService.cpp @@ -281,3 +281,44 @@ void CustodyService::ReconcileScan(bool legacyDryRun, } } } + +void CustodyService::LogReconcileReport( + char const* phase, CustodyReconcileReport const& report) +{ + sLog.outString("custody reconcile %s: confirmed=%u pending=%u " + "sweep-owned=%u rows=" UI64FMTD, + phase, report.confirmedDriftCount, + report.pendingBidCount, report.sweepOwnedCount, + report.rowVisits); + + CustodyDetailBudget budget(100); + for (size_t i = 0; i < report.findings.size(); ++i) + { + CustodyFinding const& finding = report.findings[i]; + if (!budget.Take()) + { + continue; + } + + sLog.outError("custody reconcile %s detail: auction=%u key=%s " + "reason=%s", phase, finding.row.auctionId, + finding.row.idemKey.c_str(), + CustodyFindingReasonName(finding.reason)); + } + + if (budget.Suppressed()) + { + sLog.outString("custody reconcile %s: %u detail(s) suppressed", + phase, budget.Suppressed()); + } +} + +CustodyMaintenancePlan CustodyService::GetMaintenancePlan( + bool custodyEnabled, bool writeAuthorityEnabled) +{ + CustodyMaintenancePlan plan; + plan.reconcile = custodyEnabled; + plan.prune = custodyEnabled; + plan.sweepBotMaterializations = writeAuthorityEnabled; + return plan; +} diff --git a/src/game/AuctionHouseBot/CustodyService.h b/src/game/AuctionHouseBot/CustodyService.h index 04028c138..6b930de81 100644 --- a/src/game/AuctionHouseBot/CustodyService.h +++ b/src/game/AuctionHouseBot/CustodyService.h @@ -258,6 +258,12 @@ namespace CustodyService CustodyReconcileReport& report); void ReconcileScan(bool legacyDryRun, std::vector& legacyDrift); + + void LogReconcileReport(char const* phase, + CustodyReconcileReport const& report); + + CustodyMaintenancePlan GetMaintenancePlan(bool custodyEnabled, + bool writeAuthorityEnabled); } #endif // MANGOS_CUSTODY_SERVICE_H diff --git a/src/game/Object/AuctionHouseMgr.cpp b/src/game/Object/AuctionHouseMgr.cpp index 2dcfeba02..4e9234a6d 100644 --- a/src/game/Object/AuctionHouseMgr.cpp +++ b/src/game/Object/AuctionHouseMgr.cpp @@ -60,13 +60,15 @@ static void AuditCustodyReconcile(char const* phase) { - std::vector drift; - CustodyService::ReconcileScan(false, drift); - if (!drift.empty()) + if (!sWorld.IsAhCustodyEnabled()) { - sLog.outError("custody reconcile %s: %u drift row(s) detected", - phase, uint32(drift.size())); + return; } + + CustodyReconcileReport report; + CustodyService::ReconcileScan(static_cast(time(NULL)), + CUSTODY_SCAN_BOOT, report); + CustodyService::LogReconcileReport(phase, report); } /** diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index 8cf6a4639..7398ba421 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -1283,15 +1283,18 @@ void World::Update(uint32 diff) } else if (now >= s_nextCustodyReconcileTime) { - std::vector drift; - CustodyService::ReconcileScan(true, drift); - if (!drift.empty()) + CustodyMaintenancePlan const plan = + CustodyService::GetMaintenancePlan(IsAhCustodyEnabled(), + IsAhWriteAuthority()); + if (plan.reconcile) { - sLog.outError("custody reconcile sweep: %u drift row(s) detected", - uint32(drift.size())); + CustodyReconcileReport report; + CustodyService::ReconcileScan(now, CUSTODY_SCAN_RUNTIME, + report); + CustodyService::LogReconcileReport("hourly", report); } - if (now > custodyTerminalRetention) + if (plan.prune && now > custodyTerminalRetention) { CustodyLedger::DeleteTerminalOlderThan(now - custodyTerminalRetention); } @@ -1301,7 +1304,7 @@ void World::Update(uint32 diff) // between the materialize reply and the book-commit). Only under // WriteAuthority -- the legacy in-process bot owns its own book and // never mints via this path. - if (IsAhWriteAuthority()) + if (plan.sweepBotMaterializations) { sAuctionIntentExecutor.SweepOrphanMaterializations(uint32(now)); } diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 807b2ab3f..4a443d019 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -465,6 +465,99 @@ static bool RunPureCustodyReconcilerTests() } } + // Automatic custody maintenance gates reconciliation/pruning and the bot + // materialization sweep independently. + { + CustodyMaintenancePlan const disabled = + CustodyService::GetMaintenancePlan(false, false); + CustodyMaintenancePlan const custodyOnly = + CustodyService::GetMaintenancePlan(true, false); + CustodyMaintenancePlan const writeOnly = + CustodyService::GetMaintenancePlan(false, true); + CustodyMaintenancePlan const enabled = + CustodyService::GetMaintenancePlan(true, true); + + if (disabled.reconcile || disabled.prune || + disabled.sweepBotMaterializations || + !custodyOnly.reconcile || !custodyOnly.prune || + custodyOnly.sweepBotMaterializations || + writeOnly.reconcile || writeOnly.prune || + !writeOnly.sweepBotMaterializations || + !enabled.reconcile || !enabled.prune || + !enabled.sweepBotMaterializations) + { + printf("custody FAIL: maintenance gate truth table mismatch\n"); + pass = false; + } + } + + // Each automatic/manual operation owns one independent detail budget. + { + CustodyDetailBudget budget(100); + uint32 allowed = 0; + for (uint32 i = 0; i < 101; ++i) + { + if (budget.Take()) + { + ++allowed; + } + } + + CustodyDetailBudget second(100); + if (allowed != 100 || budget.Allowed() != 100 || + budget.Suppressed() != 1 || !second.Take() || + second.Allowed() != 1 || second.Suppressed() != 0) + { + printf("custody FAIL: detail budget did not cap at 100\n"); + pass = false; + } + } + + // Shuffled input yields deterministic sorted detail output without + // changing exact category totals when the first 100 details are selected. + { + std::vector groups; + for (uint32 i = 0; i < 101; ++i) + { + uint32 const auctionId = 990100 - i; + CustodySnapshotGroup group = {}; + group.auctionId = auctionId; + group.rows.push_back(TestCustodyRow(i + 1, + "test:budget:" + std::to_string(auctionId), CUSTODY_GOLD, + ROLE_BID, 4000000 + i, 100 + i, 0, auctionId)); + groups.push_back(group); + } + + CustodyReconciler reconciler; + CustodyReconcileReport report; + reconciler.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); + CustodyDetailBudget budget(100); + uint32 firstAuctionId = 0; + uint32 lastAuctionId = 0; + for (size_t i = 0; i < report.findings.size(); ++i) + { + if (!budget.Take()) + { + continue; + } + if (!firstAuctionId) + { + firstAuctionId = report.findings[i].row.auctionId; + } + lastAuctionId = report.findings[i].row.auctionId; + } + + if (report.findings.size() != 101 || + report.confirmedDriftCount != 101 || + report.pendingBidCount != 0 || report.sweepOwnedCount != 0 || + firstAuctionId != 990000 || lastAuctionId != 990099 || + budget.Allowed() != 100 || budget.Suppressed() != 1) + { + printf("custody FAIL: bounded report order/totals mismatch\n"); + pass = false; + } + } + return pass; } From 6b116f769a3deb195adc2be423fa2adddd101400 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 12:38:46 +0100 Subject: [PATCH 05/18] fix(ah): bound and harden custody repair output --- src/game/AuctionHouseBot/CustodyService.cpp | 17 - src/game/AuctionHouseBot/CustodyService.h | 16 +- src/game/ChatCommands/AhServiceCommands.cpp | 404 ++++++++++++-------- src/mangosd/MangosdTest.cpp | 334 +++++++++++++++- 4 files changed, 563 insertions(+), 208 deletions(-) diff --git a/src/game/AuctionHouseBot/CustodyService.cpp b/src/game/AuctionHouseBot/CustodyService.cpp index e94bd5abf..bc6622905 100644 --- a/src/game/AuctionHouseBot/CustodyService.cpp +++ b/src/game/AuctionHouseBot/CustodyService.cpp @@ -265,23 +265,6 @@ void CustodyService::ReconcileScan(uint64 now, CustodyScanContext context, s_reconciler.Scan(snapshot, now, context, report); } -void CustodyService::ReconcileScan(bool legacyDryRun, - std::vector& legacyDrift) -{ - CustodyReconcileReport report; - ReconcileScan(static_cast(time(NULL)), - legacyDryRun ? CUSTODY_SCAN_RUNTIME : CUSTODY_SCAN_BOOT, report); - for (size_t i = 0; i < report.findings.size(); ++i) - { - CustodyFinding const& finding = report.findings[i]; - if (finding.state == CUSTODY_FINDING_CONFIRMED && - finding.repairOwnership == CUSTODY_REPAIR_GENERIC) - { - legacyDrift.push_back(finding.row); - } - } -} - void CustodyService::LogReconcileReport( char const* phase, CustodyReconcileReport const& report) { diff --git a/src/game/AuctionHouseBot/CustodyService.h b/src/game/AuctionHouseBot/CustodyService.h index 6b930de81..034ec8d21 100644 --- a/src/game/AuctionHouseBot/CustodyService.h +++ b/src/game/AuctionHouseBot/CustodyService.h @@ -242,23 +242,11 @@ namespace CustodyService /// CommitTransactionChecked(). Inert when the config is empty (live default). bool CommitCheckedOrForcedFail(std::string const& phase); - /** - * @brief Audit custody-ledger drift. - * - * Scans non-terminal custody rows and loaded auction maps. Drift means a - * non-terminal row with no live auction, or a live custody auction missing - * one of its required non-terminal rows (`item:`, `dep:`, and a - * live bid row when `bidder != 0`). This is audit-only: @p dryRun is kept - * for the repair command's interface but this function never mutates state. - * - * @param dryRun Audit-only flag; no mutations happen in either mode. - * @param orphans Destination vector; drift rows are appended. - */ + /// Classify one authoritative reserved-custody/shared-auction snapshot. + /// This audit-only operation never mutates custody or auction state. void ReconcileScan(uint64 now, CustodyScanContext context, CustodyReconcileReport& report); - void ReconcileScan(bool legacyDryRun, std::vector& legacyDrift); - void LogReconcileReport(char const* phase, CustodyReconcileReport const& report); diff --git a/src/game/ChatCommands/AhServiceCommands.cpp b/src/game/ChatCommands/AhServiceCommands.cpp index bcffd41d4..fe19c6f50 100644 --- a/src/game/ChatCommands/AhServiceCommands.cpp +++ b/src/game/ChatCommands/AhServiceCommands.cpp @@ -46,7 +46,6 @@ #include "Chat.h" #include "Database/DatabaseEnv.h" #include "Item.h" -#include "Log.h" #include "Mail.h" #include "ObjectMgr.h" #include "PlayerMutations.h" @@ -58,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -118,23 +118,16 @@ static char const* CustodyRoleName(uint8 role) return "proceeds"; case ROLE_ITEM: return "item"; + case ROLE_RESOLUTION: + return "resolution"; default: return "unknown"; } } -static bool HasLiveAuction(uint32 auctionId) +static bool IsBotMarkerKey(std::string const& key) { - for (uint32 i = 0; i < MAX_AUCTION_HOUSE_TYPE; ++i) - { - AuctionHouseObject* auctions = sAuctionMgr.GetAuctionsMap(AuctionHouseType(i)); - if (auctions->GetAuction(auctionId)) - { - return true; - } - } - - return false; + return key.compare(0, 8, "botlist:") == 0; } static bool TerminalizeCustodyRow(CustodyRow const& row, uint8 terminalState) @@ -209,8 +202,6 @@ static bool ReadCommittedCancelJournal(uint32 auctionId, PlayerMutationResult& o std::string bin; if (!RepairHexDecode(q->Fetch()[0].GetCppString(), bin)) { - sLog.outError("ah repair: committed cancel journal facts decode failed for auction %u", - auctionId); return false; } @@ -219,8 +210,6 @@ static bool ReadCommittedCancelJournal(uint32 auctionId, PlayerMutationResult& o PlayerMutationResult res; if (!res.Decode(bb) || res.facts.auctionId != auctionId) { - sLog.outError("ah repair: committed cancel journal facts invalid for auction %u", - auctionId); return false; } @@ -243,8 +232,6 @@ static Item* LoadRepairItemFromDb(uint32 itemGuid, uint32 itemTemplate, ItemPrototype const* proto = ObjectMgr::GetItemPrototype(itemTemplate); if (!proto) { - sLog.outError("ah repair: item template %u missing while repairing item %u", - itemTemplate, itemGuid); return NULL; } @@ -253,8 +240,6 @@ static Item* LoadRepairItemFromDb(uint32 itemGuid, uint32 itemTemplate, itemGuid)); if (!q) { - sLog.outError("ah repair: item_instance %u missing during committed cancel repair", - itemGuid); return NULL; } @@ -262,36 +247,60 @@ static Item* LoadRepairItemFromDb(uint32 itemGuid, uint32 itemTemplate, if (!item->LoadFromDB(itemGuid, q->Fetch(), ObjectGuid(HIGHGUID_PLAYER, ownerGuid))) { delete item; - sLog.outError("ah repair: item_instance %u failed LoadFromDB during committed cancel repair", - itemGuid); return NULL; } return item; } -bool AhRepairCommittedCancelAuction(uint32 auctionId, uint32& repairedRows) +enum AhRepairActionStatus { - repairedRows = 0u; + AH_REPAIR_REPAIRED, + AH_REPAIR_SKIPPED, + AH_REPAIR_FAILED, +}; - if (HasLiveAuction(auctionId)) +struct AhRepairActionResult +{ + AhRepairActionStatus status; + uint32 repairedRows; + std::string detail; +}; + +static AhRepairActionResult RepairAction(AhRepairActionStatus status, + uint32 repairedRows, + std::string const& detail) +{ + AhRepairActionResult result; + result.status = status; + result.repairedRows = repairedRows; + result.detail = detail; + return result; +} + +static AhRepairActionResult RepairCommittedCancelAuction(uint32 auctionId) +{ + std::ostringstream detail; + if (CustodyLedger::AuctionExists(auctionId)) { - return false; + detail << "committed cancel auction " << auctionId + << " became live; skipped"; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } PlayerMutationResult stored; if (!ReadCommittedCancelJournal(auctionId, stored)) { - return false; + detail << "committed cancel journal invalid for auction " << auctionId; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } MutationFacts const& f = stored.facts; if (f.curBid != 0u || f.curBidderGuid != 0u) { - sLog.outError("ah repair: committed cancel repair for auction %u has a live bid; " - "bidder refund replay is not supported by this repair path yet", - auctionId); - return false; + detail << "committed cancel auction " << auctionId + << " has a live bid; replay unsupported"; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } std::string const depKey = "dep:" + std::to_string(auctionId); @@ -309,7 +318,9 @@ bool AhRepairCommittedCancelAuction(uint32 auctionId, uint32& repairedRows) if (!hasDep && !hasItem) { - return false; + detail << "committed cancel auction " << auctionId + << " has no repairable reserved rows"; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } Item* item = NULL; @@ -317,17 +328,20 @@ bool AhRepairCommittedCancelAuction(uint32 auctionId, uint32& repairedRows) { if (itemRow.ownerGuid != f.sellerGuid || itemRow.itemGuid != f.itemGuid) { - sLog.outError("ah repair: committed cancel custody mismatch for auction %u", - auctionId); - return false; + detail << "committed cancel custody mismatch for auction " + << auctionId; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } item = LoadRepairItemFromDb(f.itemGuid, f.itemTemplate, f.sellerGuid); if (!item) { - return false; + detail << "committed cancel item unavailable for auction " + << auctionId; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } } + uint32 repairedRows = 0; CustodyDeferred def; CharacterDatabase.BeginTransaction(); @@ -356,16 +370,15 @@ bool AhRepairCommittedCancelAuction(uint32 auctionId, uint32& repairedRows) if (!CharacterDatabase.CommitTransactionChecked()) { - sLog.outError("ah repair: committed cancel repair commit failed for auction %u", - auctionId); - repairedRows = 0u; - return false; + detail << "committed cancel repair commit failed for auction " + << auctionId; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } def.run(); - sLog.outString("ah repair: replayed committed cancel for auction %u from journal", - auctionId); - return true; + detail << "replayed committed cancel for auction " << auctionId + << ", repaired=" << repairedRows; + return RepairAction(AH_REPAIR_REPAIRED, repairedRows, detail.str()); } static bool ExtractForceForfeitKey(std::string const& mode, std::string& key) @@ -387,132 +400,171 @@ static bool ExtractForceForfeitKey(std::string const& mode, std::string& key) return false; } -static void PrintRepairRow(ChatHandler& handler, char const* prefix, - CustodyRow const& row) +static void PrintRepairFinding(ChatHandler& handler, + CustodyDetailBudget& budget, + char const* mode, + CustodyFinding const& finding) { + if (!budget.Take()) + { + return; + } + + CustodyRow const& row = finding.row; handler.PSendSysMessage( - "%s key=%s auction=%u kind=%s role=%s state=%u owner=%u amount=%u item=%u", - prefix, row.idemKey.c_str(), row.auctionId, + "ah repair detail: mode=%s key=%s auction=%u kind=%s role=%s " + "row-state=%u owner=%u amount=%u item=%u reason=%s ownership=%s " + "finding-state=%s", + mode, row.idemKey.c_str(), row.auctionId, CustodyKindName(row.kind), CustodyRoleName(row.role), - uint32(row.state), row.ownerGuid, row.amount, row.itemGuid); + uint32(row.state), row.ownerGuid, row.amount, row.itemGuid, + CustodyFindingReasonName(finding.reason), + CustodyRepairOwnershipName(finding.repairOwnership), + CustodyFindingStateName(finding.state)); } -static bool AuctionAlreadyHandled(std::vector const& handled, - uint32 auctionId) +static void PrintRepairAction(ChatHandler& handler, + CustodyDetailBudget& budget, + AhRepairActionResult const& result) { - for (size_t i = 0; i < handled.size(); ++i) + if (budget.Take()) { - if (handled[i] == auctionId) - { - return true; - } + handler.PSendSysMessage("ah repair action: %s", result.detail.c_str()); } - return false; } -static bool RepairGoldRow(ChatHandler& handler, CustodyRow const& row) +static void PrintRepairSuppression(ChatHandler& handler, + CustodyDetailBudget const& budget) { - sLog.outError("ah repair: terminalizing orphan gold custody row without disbursement key=%s auction=%u owner=%u amount=%u", - row.idemKey.c_str(), row.auctionId, row.ownerGuid, row.amount); - if (!TerminalizeCustodyRow(row, CST_TERMINAL_OK)) + if (budget.Suppressed()) { - sLog.outError("ah repair: checked commit failed for gold row key=%s", - row.idemKey.c_str()); - handler.PSendSysMessage("ah repair: checked commit failed for %s.", - row.idemKey.c_str()); - return false; + handler.PSendSysMessage("ah repair: %u detail(s) suppressed.", + budget.Suppressed()); } +} - handler.PSendSysMessage("ah repair: terminalized gold row %s without disbursing %u copper.", - row.idemKey.c_str(), row.amount); - return true; +static void PrintRepairSummary(ChatHandler& handler, char const* mode, + CustodyReconcileReport const& report, + uint32 repaired, uint32 skipped, uint32 failed) +{ + handler.PSendSysMessage( + "ah repair: complete mode=%s confirmed=%u pending=%u sweep-owned=%u " + "repaired=%u skipped=%u failed=%u.", + mode, report.confirmedDriftCount, report.pendingBidCount, + report.sweepOwnedCount, repaired, skipped, failed); } -static bool RepairItemRow(ChatHandler& handler, CustodyRow const& row) +bool AhRepairFindingMutationAllowed(CustodyFinding const& finding) { - sLog.outError("ah repair: terminalizing orphan item custody row without re-mailing key=%s auction=%u item=%u owner=%u; manually verify mAitems/item_instance for residual item_guid=%u", - row.idemKey.c_str(), row.auctionId, row.itemGuid, - row.ownerGuid, row.itemGuid); + CustodyRow const& row = finding.row; + return finding.state == CUSTODY_FINDING_CONFIRMED && + finding.repairOwnership == CUSTODY_REPAIR_GENERIC && + !IsBotMarkerKey(row.idemKey) && row.id != 0 && + row.state == CST_RESERVED && + !CustodyLedger::AuctionExists(row.auctionId); +} +static AhRepairActionResult RepairGoldRow(CustodyRow const& row) +{ + std::ostringstream detail; if (!TerminalizeCustodyRow(row, CST_TERMINAL_OK)) { - handler.PSendSysMessage("ah repair: failed to terminalize item row %s.", - row.idemKey.c_str()); - return false; + detail << "checked commit failed for gold row " << row.idemKey; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } - handler.PSendSysMessage("ah repair: terminalized item row %s without re-mailing; verify item %u manually.", - row.idemKey.c_str(), row.itemGuid); - return true; + detail << "terminalized gold row " << row.idemKey + << " without disbursing " << row.amount << " copper"; + return RepairAction(AH_REPAIR_REPAIRED, 1, detail.str()); } -static bool RepairCustodyRow(ChatHandler& handler, CustodyRow const& row) +static AhRepairActionResult RepairItemRow(CustodyRow const& row) { - if (row.id == 0) + std::ostringstream detail; + if (!TerminalizeCustodyRow(row, CST_TERMINAL_OK)) { - handler.PSendSysMessage("ah repair: cannot auto-repair synthetic drift %s.", - row.idemKey.c_str()); - return false; + detail << "failed to terminalize item row " << row.idemKey; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } - if (row.state != CST_RESERVED) - { - handler.PSendSysMessage("ah repair: skipping non-reserved row %s state=%u.", - row.idemKey.c_str(), uint32(row.state)); - return false; - } + detail << "terminalized item row " << row.idemKey + << " without re-mailing; verify item " << row.itemGuid; + return RepairAction(AH_REPAIR_REPAIRED, 1, detail.str()); +} - if (HasLiveAuction(row.auctionId)) +static AhRepairActionResult RepairCustodyRow(CustodyFinding const& finding) +{ + CustodyRow const& row = finding.row; + std::ostringstream detail; + if (!AhRepairFindingMutationAllowed(finding)) { - handler.PSendSysMessage("ah repair: skipping live-auction drift %s; use force-forfeit after manual verification.", - row.idemKey.c_str()); - return false; + detail << "skipped ineligible or live custody row " << row.idemKey; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } if (row.kind == CUSTODY_ITEM) { - return RepairItemRow(handler, row); + return RepairItemRow(row); } if (row.kind == CUSTODY_GOLD) { - return RepairGoldRow(handler, row); + return RepairGoldRow(row); } - handler.PSendSysMessage("ah repair: skipping unknown custody kind for %s.", - row.idemKey.c_str()); - return false; + detail << "skipped unknown custody kind for " << row.idemKey; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } -static bool ForceForfeitRow(ChatHandler& handler, CustodyRow const& row) +static AhRepairActionResult ForceForfeitRow(CustodyRow const& row) { + std::ostringstream detail; + if (IsBotMarkerKey(row.idemKey)) + { + detail << "reserved bot marker " << row.idemKey + << " cannot be force-forfeited"; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); + } + if (row.id == 0) { - handler.PSendSysMessage("ah repair: cannot force-forfeit synthetic drift %s.", - row.idemKey.c_str()); - return false; + detail << "cannot force-forfeit synthetic drift " << row.idemKey; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } if (row.state != CST_RESERVED) { - handler.PSendSysMessage("ah repair: cannot force-forfeit non-reserved row %s state=%u.", - row.idemKey.c_str(), uint32(row.state)); - return false; + detail << "cannot force-forfeit non-reserved row " << row.idemKey; + return RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); } - sLog.outError("ah repair: force-forfeit terminalizing custody row without disbursement key=%s auction=%u kind=%u role=%u owner=%u amount=%u item=%u", - row.idemKey.c_str(), row.auctionId, uint32(row.kind), - uint32(row.role), row.ownerGuid, row.amount, row.itemGuid); if (!TerminalizeCustodyRow(row, CST_TERMINAL_OK)) { - handler.PSendSysMessage("ah repair: force-forfeit commit failed for %s.", - row.idemKey.c_str()); - return false; + detail << "force-forfeit commit failed for " << row.idemKey; + return RepairAction(AH_REPAIR_FAILED, 0, detail.str()); } - handler.PSendSysMessage("ah repair: force-forfeit terminalized %s without disbursement or item mail.", - row.idemKey.c_str()); - return true; + detail << "force-forfeit terminalized " << row.idemKey + << " without disbursement or item mail"; + return RepairAction(AH_REPAIR_REPAIRED, 1, detail.str()); +} + +static void CountRepairAction(AhRepairActionResult const& result, + uint32& repaired, uint32& skipped, + uint32& failed) +{ + switch (result.status) + { + case AH_REPAIR_REPAIRED: + repaired += result.repairedRows; + break; + case AH_REPAIR_SKIPPED: + ++skipped; + break; + case AH_REPAIR_FAILED: + ++failed; + break; + } } // --------------------------------------------------------------------------- @@ -616,93 +668,105 @@ bool ChatHandler::HandleAhRepairCommand(char* args) return false; } - std::vector drift; - CustodyService::ReconcileScan(true, drift); + CustodyReconcileReport report; + CustodyService::ReconcileScan(static_cast(time(NULL)), + CUSTODY_SCAN_RUNTIME, report); - PSendSysMessage("ah repair: %u custody-ledger drift row(s) found.", - uint32(drift.size())); - SendSysMessage("ah repair: committed cancel journal rows are replayed; other legacy tears are not repaired."); + CustodyDetailBudget budget(100); + uint32 repaired = 0; + uint32 skipped = 0; + uint32 failed = 0; - if (drift.empty()) + if (forceForfeit) { - if (forceForfeit) + AhRepairActionResult result = RepairAction( + AH_REPAIR_SKIPPED, 0, + "force-forfeit key is not current drift: " + forceForfeitKey); + bool found = false; + if (IsBotMarkerKey(forceForfeitKey)) { - PSendSysMessage("ah repair: force-forfeit key %s is not current drift.", - forceForfeitKey.c_str()); - SetSentErrorMessage(true); - return false; + result = RepairAction(AH_REPAIR_SKIPPED, 0, + "reserved bot marker " + forceForfeitKey + + " cannot be force-forfeited"); } - - return true; - } - - if (forceForfeit) - { - for (size_t i = 0; i < drift.size(); ++i) + else { - if (drift[i].idemKey == forceForfeitKey) + for (size_t i = 0; i < report.findings.size(); ++i) { - return ForceForfeitRow(*this, drift[i]); + if (report.findings[i].row.idemKey == forceForfeitKey) + { + found = true; + result = ForceForfeitRow(report.findings[i].row); + break; + } } } - PSendSysMessage("ah repair: force-forfeit key %s is not current drift.", - forceForfeitKey.c_str()); - SetSentErrorMessage(true); - return false; + CountRepairAction(result, repaired, skipped, failed); + PrintRepairAction(*this, budget, result); + PrintRepairSuppression(*this, budget); + PrintRepairSummary(*this, "force-forfeit", report, + repaired, skipped, failed); + if (!found || result.status != AH_REPAIR_REPAIRED) + { + SetSentErrorMessage(true); + return false; + } + return true; } - for (size_t i = 0; i < drift.size(); ++i) + char const* modeName = apply ? "apply" : "dry-run"; + for (size_t i = 0; i < report.findings.size(); ++i) { - PrintRepairRow(*this, apply ? "apply:" : "dry-run:", drift[i]); + PrintRepairFinding(*this, budget, modeName, report.findings[i]); } if (!apply) { - SendSysMessage("ah repair: dry-run only. Use 'ah repair apply' or 'ah repair force-forfeit ' to mutate supported rows."); + PrintRepairSuppression(*this, budget); + PrintRepairSummary(*this, modeName, report, 0, 0, 0); return true; } - uint32 repaired = 0; - uint32 skipped = 0; - std::vector handledAuctions; - for (size_t i = 0; i < drift.size(); ++i) + std::set handledJournalAuctions; + for (size_t i = 0; i < report.findings.size(); ++i) { - if (AuctionAlreadyHandled(handledAuctions, drift[i].auctionId)) - { - continue; - } - - uint32 replayedRows = 0u; - if (AhRepairCommittedCancelAuction(drift[i].auctionId, replayedRows)) - { - handledAuctions.push_back(drift[i].auctionId); - repaired += replayedRows; - PSendSysMessage("ah repair: replayed committed cancel for auction %u, repaired=%u.", - drift[i].auctionId, replayedRows); - continue; - } + CustodyFinding const& finding = report.findings[i]; + CustodyRow const& row = finding.row; + AhRepairActionResult result; - if (HasCommittedCancelJournal(drift[i].auctionId)) + if (finding.state != CUSTODY_FINDING_CONFIRMED || + finding.repairOwnership != CUSTODY_REPAIR_GENERIC || + IsBotMarkerKey(row.idemKey)) { - handledAuctions.push_back(drift[i].auctionId); - ++skipped; - PSendSysMessage("ah repair: committed cancel replay failed for auction %u; skipped.", - drift[i].auctionId); + std::ostringstream detail; + detail << "skipped " << row.idemKey << " ownership=" + << CustodyRepairOwnershipName(finding.repairOwnership) + << " state=" << CustodyFindingStateName(finding.state); + result = RepairAction(AH_REPAIR_SKIPPED, 0, detail.str()); + CountRepairAction(result, repaired, skipped, failed); + PrintRepairAction(*this, budget, result); continue; } - if (RepairCustodyRow(*this, drift[i])) + if (HasCommittedCancelJournal(row.auctionId)) { - ++repaired; + if (!handledJournalAuctions.insert(row.auctionId).second) + { + continue; + } + result = RepairCommittedCancelAuction(row.auctionId); } else { - ++skipped; + result = RepairCustodyRow(finding); } + + CountRepairAction(result, repaired, skipped, failed); + PrintRepairAction(*this, budget, result); } - PSendSysMessage("ah repair: apply complete, repaired=%u skipped=%u.", - repaired, skipped); - return true; + PrintRepairSuppression(*this, budget); + PrintRepairSummary(*this, modeName, report, repaired, skipped, failed); + return failed == 0; } diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 4a443d019..e37fcf8dc 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -32,10 +32,33 @@ bool AhBuildCancelPrepareForward(MutationPendingMap& pending, uint32 playerGuidLow, uint32 auctionId, uint64 uuid, uint32 sentSec, IpcMessage& out); -bool AhRepairCommittedCancelAuction(uint32 auctionId, uint32& repairedRows); +bool AhRepairFindingMutationAllowed(CustodyFinding const& finding); -static void TestCliPrint(void* /*arg*/, char const* /*text*/) +struct TestCliCapture { + std::vector chunks; +}; + +static void TestCliPrint(void* arg, char const* text) +{ + if (arg && text) + { + static_cast(arg)->chunks.push_back(text); + } +} + +static uint32 CountCliChunks(TestCliCapture const& capture, + std::string const& needle) +{ + uint32 count = 0; + for (size_t i = 0; i < capture.chunks.size(); ++i) + { + if (capture.chunks[i].find(needle) != std::string::npos) + { + ++count; + } + } + return count; } static std::string TestHexEncode(ByteBuffer const& bb) @@ -1369,6 +1392,241 @@ static int RunCustodyTest() CharacterDatabase.DirectExecute( "DELETE FROM `mail` WHERE `receiver`=1 AND `subject`='AH custody repair'"); + // The apply guard must consult the shared auction table at mutation time, + // not the local AH map or the auction facts captured by the scan. + { + uint32 const livenessAuctionId = 970080; + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", livenessAuctionId); + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='test:repair:liveness'"); + CharacterDatabase.DirectPExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:repair:liveness',0,1,0,1,0,80,0,%u," UI64FMTD ",0)", + livenessAuctionId, oldTime); + + CustodyReconcileReport report; + CustodyService::ReconcileScan(now, CUSTODY_SCAN_RUNTIME, report); + CustodyFinding scanned = {}; + bool found = false; + for (size_t i = 0; i < report.findings.size(); ++i) + { + if (report.findings[i].row.idemKey == "test:repair:liveness") + { + scanned = report.findings[i]; + found = true; + break; + } + } + + CharacterDatabase.DirectPExecute( + "INSERT INTO `auction` " + "(`id`,`houseid`,`itemguid`,`item_template`,`item_count`," + "`item_randompropertyid`,`itemowner`,`buyoutprice`,`time`,`buyguid`," + "`lastbid`,`startbid`,`deposit`) " + "VALUES (%u,7,880080,25,1,0,1,0," UI64FMTD ",0,0,10,5)", + livenessAuctionId, now + HOUR); + bool const blockedAfterInsert = found && + !AhRepairFindingMutationAllowed(scanned); + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", livenessAuctionId); + bool const allowedAfterDelete = found && + AhRepairFindingMutationAllowed(scanned); + if (!found || !blockedAfterInsert || !allowedAfterDelete) + { + printf("custody FAIL: repair liveness guard ignored post-scan DB state\n"); + pass = false; + } + + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='test:repair:liveness'"); + } + + // Generic apply must report but skip pending, manual-only, and bot-sweep + // findings. Force-forfeit must reject the reserved bot marker explicitly. + { + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'botlist:test:repair:%'"); + CharacterDatabase.DirectExecute( + "DELETE FROM `auction` WHERE `id` IN (970081,970082)"); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `auction` " + "(`id`,`houseid`,`itemguid`,`item_template`,`item_count`," + "`item_randompropertyid`,`itemowner`,`buyoutprice`,`time`,`buyguid`," + "`lastbid`,`startbid`,`deposit`) VALUES " + "(970081,7,880081,25,1,0,%u,0," UI64FMTD ",0,0,10,0)," + "(970082,7,880082,25,1,0,%u,0," UI64FMTD ",2082,82,10,0)", + AHBOT_SYSTEM_OWNER_GUID, now + HOUR, + AHBOT_SYSTEM_OWNER_GUID, now + HOUR); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) VALUES " + "('botlist:test:repair:manual',1,3,0,%u,0,0,880081,970081," UI64FMTD ",0)," + "('botlist:test:repair:pending',1,4,0,%u,0,0,880082,970082," UI64FMTD ",0)," + "('botlist:test:repair:sweep',1,4,0,%u,0,0,880083,970083," UI64FMTD ",0)", + AHBOT_SYSTEM_OWNER_GUID, oldTime, + AHBOT_SYSTEM_OWNER_GUID, oldTime, + AHBOT_SYSTEM_OWNER_GUID, oldTime); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("custody FAIL: repair ownership fixtures failed to commit\n"); + pass = false; + } + + TestCliCapture applyCapture; + CliHandler applyCli(0, SEC_ADMINISTRATOR, &applyCapture, &TestCliPrint); + if (!applyCli.ParseCommands("ah repair apply") || + CountCliChunks(applyCapture, + "mode=apply confirmed=1 pending=1 sweep-owned=1 repaired=0 skipped=3 failed=0") != 1) + { + printf("custody FAIL: repair ownership summary mismatch\n"); + pass = false; + } + + char const* markerKeys[] = { + "botlist:test:repair:manual", + "botlist:test:repair:pending", + "botlist:test:repair:sweep", + }; + for (size_t i = 0; i < sizeof(markerKeys) / sizeof(markerKeys[0]); ++i) + { + CustodyRow markerRow; + if (!CustodyLedger::Get(markerKeys[i], markerRow) || + markerRow.state != CST_RESERVED) + { + printf("custody FAIL: generic apply mutated reserved bot marker %s\n", + markerKeys[i]); + pass = false; + } + } + + TestCliCapture forceCapture; + CliHandler forceCli(0, SEC_ADMINISTRATOR, &forceCapture, &TestCliPrint); + forceCli.ParseCommands( + "ah repair force-forfeit botlist:test:repair:sweep"); + if (CountCliChunks(forceCapture, "reserved bot marker") != 1) + { + printf("custody FAIL: force-forfeit did not reject bot marker\n"); + pass = false; + } + + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'botlist:test:repair:%'"); + CharacterDatabase.DirectExecute( + "DELETE FROM `auction` WHERE `id` IN (970081,970082)"); + } + + // Failed journal replay is counted separately and leaves custody reserved. + { + uint32 const failedAuctionId = 970084; + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='test:repair:failed'"); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` WHERE `auction_id`=%u", + failedAuctionId); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:repair:failed',0,1,0,1,0,84,0,%u," UI64FMTD ",0)", + failedAuctionId, oldTime); + CharacterDatabase.PExecute( + "INSERT INTO `ah_worker_journal` " + "(`uuid`,`auction_id`,`kind`,`state`,`facts`,`created_time`,`resolved_time`) " + "VALUES (970084,%u,%u,1,'00'," UI64FMTD "," UI64FMTD ")", + failedAuctionId, uint32(IPC_PLAYER_CANCEL & 0xFFu), + oldTime, oldTime); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("custody FAIL: failed-replay fixtures did not commit\n"); + pass = false; + } + + TestCliCapture failedCapture; + CliHandler failedCli(0, SEC_ADMINISTRATOR, + &failedCapture, &TestCliPrint); + failedCli.ParseCommands("ah repair apply"); + CustodyRow failedRow; + if (CountCliChunks(failedCapture, + "mode=apply confirmed=1 pending=0 sweep-owned=0 repaired=0 skipped=0 failed=1") != 1 || + !CustodyLedger::Get("test:repair:failed", failedRow) || + failedRow.state != CST_RESERVED) + { + printf("custody FAIL: failed repair summary/state mismatch\n"); + pass = false; + } + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='test:repair:failed'"); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` WHERE `auction_id`=%u", + failedAuctionId); + } + + // One budget spans scan and action details. Exact totals remain uncapped. + { + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:repair:budget:%'"); + CharacterDatabase.BeginTransaction(); + for (uint32 i = 0; i < 101; ++i) + { + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:repair:budget:%u',0,1,0,1,0,%u,0,%u," UI64FMTD ",0)", + i, 100 + i, 974000 + i, oldTime); + } + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("custody FAIL: repair budget fixtures failed to commit\n"); + pass = false; + } + + TestCliCapture dryCapture; + CliHandler dryCli(0, SEC_ADMINISTRATOR, &dryCapture, &TestCliPrint); + if (!dryCli.ParseCommands("ah repair --dry-run") || + CountCliChunks(dryCapture, "ah repair detail:") != 100 || + CountCliChunks(dryCapture, "detail(s) suppressed") != 1 || + CountCliChunks(dryCapture, + "mode=dry-run confirmed=101 pending=0 sweep-owned=0 repaired=0 skipped=0 failed=0") != 1) + { + printf("custody FAIL: dry-run detail cap or totals mismatch\n"); + pass = false; + } + + TestCliCapture boundedApplyCapture; + CliHandler boundedApplyCli(0, SEC_ADMINISTRATOR, + &boundedApplyCapture, &TestCliPrint); + if (!boundedApplyCli.ParseCommands("ah repair apply") || + CountCliChunks(boundedApplyCapture, "ah repair detail:") + + CountCliChunks(boundedApplyCapture, "ah repair action:") != 100 || + CountCliChunks(boundedApplyCapture, "detail(s) suppressed") != 1 || + CountCliChunks(boundedApplyCapture, + "mode=apply confirmed=101 pending=0 sweep-owned=0 repaired=101 skipped=0 failed=0") != 1) + { + printf("custody FAIL: apply shared detail cap or totals mismatch\n"); + pass = false; + } + + std::unique_ptr reserved(CharacterDatabase.Query( + "SELECT COUNT(*) FROM `custody_ledger` " + "WHERE `idem_key` LIKE 'test:repair:budget:%' AND `state`=0")); + if (!reserved || reserved->Fetch()[0].GetUInt32() != 0) + { + printf("custody FAIL: bounded apply did not repair all 101 rows\n"); + pass = false; + } + + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:repair:budget:%'"); + } + // ================================================================ primitive // Primitive round-trip: offline owner (no ModifyMoney), // ReserveGold -> RollbackGoldLedgerOnly -> assert no mail. @@ -3412,15 +3670,22 @@ static int RunAhRepairRecoveryTest() pass = false; } - uint32 repairedRows = 0u; - if (!AhRepairCommittedCancelAuction(auctionId, repairedRows)) + TestCliCapture repairCapture; + CliHandler repairCli(0, SEC_ADMINISTRATOR, &repairCapture, &TestCliPrint); + if (!repairCli.ParseCommands("ah repair apply")) + { + printf("ahrepair FAIL: committed cancel command was not parsed\n"); + pass = false; + } + if (CountCliChunks(repairCapture, + "mode=apply confirmed=2 pending=0 sweep-owned=0 repaired=2 skipped=0 failed=0") != 1u) { - printf("ahrepair FAIL: committed cancel repair returned false\n"); + printf("ahrepair FAIL: committed cancel command summary mismatch\n"); pass = false; } - if (repairedRows != 2u) + if (CountCliChunks(repairCapture, "replayed committed cancel") != 1u) { - printf("ahrepair FAIL: committed cancel repairedRows=%u\n", repairedRows); + printf("ahrepair FAIL: repeated auction rows replayed journal more than once\n"); pass = false; } @@ -3478,6 +3743,61 @@ static int RunAhRepairRecoveryTest() } } + // Generic apply and force-forfeit both preserve reserved bot provenance + // markers and their materialized item instances. + Item* protectedItem = Item::CreateItem(itemId, 1); + if (!protectedItem) + { + printf("ahrepair FAIL: protected marker item creation failed\n"); + pass = false; + } + else + { + protectedItem->SetOwnerGuid(ObjectGuid(HIGHGUID_PLAYER, + AHBOT_SYSTEM_OWNER_GUID)); + uint32 const protectedItemGuid = protectedItem->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); + protectedItem->SaveToDB(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('botlist:test:repair:protected',1,4,0,%u,0,0,%u,992002," + UI64FMTD ",0)", + AHBOT_SYSTEM_OWNER_GUID, protectedItemGuid, oldTime); + bool const markerSeeded = CharacterDatabase.CommitTransactionChecked(); + delete protectedItem; + + TestCliCapture markerApplyCapture; + CliHandler markerApplyCli(0, SEC_ADMINISTRATOR, + &markerApplyCapture, &TestCliPrint); + markerApplyCli.ParseCommands("ah repair apply"); + TestCliCapture markerForceCapture; + CliHandler markerForceCli(0, SEC_ADMINISTRATOR, + &markerForceCapture, &TestCliPrint); + markerForceCli.ParseCommands( + "ah repair force-forfeit botlist:test:repair:protected"); + + CustodyRow protectedMarker; + std::unique_ptr protectedItemRow(CharacterDatabase.PQuery( + "SELECT 1 FROM `item_instance` WHERE `guid`=%u", protectedItemGuid)); + if (!markerSeeded || + !CustodyLedger::Get("botlist:test:repair:protected", protectedMarker) || + protectedMarker.state != CST_RESERVED || !protectedItemRow || + CountCliChunks(markerApplyCapture, + "mode=apply confirmed=0 pending=0 sweep-owned=1 repaired=0 skipped=1 failed=0") != 1u || + CountCliChunks(markerForceCapture, "reserved bot marker") != 1u) + { + printf("ahrepair FAIL: apply or force-forfeit mutated bot marker/item\n"); + pass = false; + } + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='botlist:test:repair:protected'"); + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", protectedItemGuid); + } + CharacterDatabase.DirectPExecute( "DELETE FROM `mail_items` WHERE `item_guid`=%u", itemGuid); CharacterDatabase.DirectPExecute( From 9f188cf5c1a7372e1113ac46e8c0369e8857d6c9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 13:02:05 +0100 Subject: [PATCH 06/18] fix(ah): separate seller and bid custody routing --- src/game/AuctionHouseBot/CustodyLedger.cpp | 17 - src/game/AuctionHouseBot/CustodyLedger.h | 16 - src/game/Object/AuctionHouseMgr.cpp | 193 +++-- src/game/Object/AuctionHouseMgr.h | 31 +- .../WorldHandlers/AuctionHouseHandler.cpp | 104 +-- src/mangosd/MangosdTest.cpp | 721 +++++++++++++++++- .../AhWorker/tools/custody_crash_test.md | 2 +- 7 files changed, 900 insertions(+), 184 deletions(-) diff --git a/src/game/AuctionHouseBot/CustodyLedger.cpp b/src/game/AuctionHouseBot/CustodyLedger.cpp index 79a35d65d..f649e519c 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.cpp +++ b/src/game/AuctionHouseBot/CustodyLedger.cpp @@ -184,23 +184,6 @@ bool CustodyLedger::AuctionExists(uint32 auctionId) return true; } -bool CustodyLedger::HasRows(uint32 auctionId) -{ - // Only match active (CST_RESERVED) rows so terminal leftovers from a - // deleted-and-reused auction_id do not falsely open the custody path. - // A live custody auction always has at least one RESERVED row; a fully - // resolved or cancelled auction's rows are all terminal -> returns false. - QueryResult* res = CharacterDatabase.PQuery( - "SELECT 1 FROM `custody_ledger` WHERE `auction_id`=%u AND `state`=%u LIMIT 1", - auctionId, uint32(CST_RESERVED)); - if (!res) - { - return false; - } - delete res; - return true; -} - void CustodyLedger::LoadNonTerminal(std::vector& out) { QueryResult* result = CharacterDatabase.Query( diff --git a/src/game/AuctionHouseBot/CustodyLedger.h b/src/game/AuctionHouseBot/CustodyLedger.h index 64027dde8..7d6322f8c 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.h +++ b/src/game/AuctionHouseBot/CustodyLedger.h @@ -160,22 +160,6 @@ namespace CustodyLedger */ void SetAmount(std::string const& idemKey, uint32 newAmount); - /** - * @brief Return true if at least one active (CST_RESERVED) row exists for - * @p auctionId. - * - * Only RESERVED rows are matched; terminal rows (CST_TERMINAL_OK, - * CST_TERMINAL_BACK) from a previously resolved or cancelled auction are - * ignored. This prevents a deleted-then-reused auction_id from falsely - * opening the custody path via stale terminal rows. - * - * Issues a synchronous SELECT; safe to call outside a transaction. - * - * @param auctionId Auction entry id to probe. - * @return true if at least one RESERVED row with that auction_id exists. - */ - bool HasRows(uint32 auctionId); - /** * @brief Load all non-terminal rows (state == CST_RESERVED) into @p out. * diff --git a/src/game/Object/AuctionHouseMgr.cpp b/src/game/Object/AuctionHouseMgr.cpp index 4e9234a6d..9e298b3fb 100644 --- a/src/game/Object/AuctionHouseMgr.cpp +++ b/src/game/Object/AuctionHouseMgr.cpp @@ -1061,19 +1061,45 @@ void AuctionHouseObject::Update() AuctionEntryMap::iterator old = itr++; if (curTime > old->second->expireTime) { + CustodyRouteState route = {}; + if (sWorld.IsAhCustodyEnabled()) + { + route = CustodyLedger::GetRouteState(old->second->Id); + } + ///- perform the transaction if there was bidder if (old->second->bid) { - // Custody co-commit path (per-auction drain, X3): only auctions - // carrying live custody rows resolve through the ledger; legacy - // (pre-gate / bot-created) auctions fall through unchanged. + // Seller and bid custody are independent. A worker/bot listing + // can carry only a player bid row; a player listing can still + // carry a legacy bid with no bid row. // `old = itr++` already advanced the iterator, so the deferred // RemoveAuction(Id) erase of `old`'s slot does NOT invalidate itr. - if (sWorld.IsAhCustodyEnabled() && CustodyLedger::HasRows(old->second->Id)) + if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) { + std::string liveBidKey; + if (route.hasLiveBidCustody) + { + CustodyRow liveRow; + if (old->second->bidder == 0 || + !CustodyLedger::GetSingleLiveBidRow(old->second->Id, liveRow) || + liveRow.ownerGuid != old->second->bidder || + liveRow.amount != old->second->bid) + { + sLog.outError("custody S4: live bid row validation failed for auction %u " + "(bidder %u, bid %u); failing closed", + old->second->Id, old->second->bidder, + old->second->bid); + continue; + } + liveBidKey = liveRow.idemKey; + } + CustodyDeferred def; CharacterDatabase.BeginTransaction(); - old->second->AuctionBidWinningCustody(NULL, def); + old->second->AuctionBidWinningCustody( + NULL, def, route.usesPlayerSellerCustody, + route.hasLiveBidCustody, liveBidKey); CustodyService::MaybeCrash("pre-commit"); if (CharacterDatabase.CommitTransactionChecked()) { @@ -1098,13 +1124,12 @@ void AuctionHouseObject::Update() ///- cancel the auction if there was no bidder and clear the auction else // no bidder -> unsold expiry { - // Custody co-commit path (per-auction drain, X3): only auctions - // carrying live custody rows resolve through the ledger; legacy - // (pre-gate / bot-created) auctions fall through unchanged. + // Unsold expiry has no bid value to settle, so only player + // seller custody selects the custody transaction. // `old = itr++` already advanced the iterator, so the deferred // RemoveAuction(Id) erase of `old`'s slot does NOT invalidate itr // (same reasoning as the win-branch comment at :887-889). - if (sWorld.IsAhCustodyEnabled() && CustodyLedger::HasRows(old->second->Id)) + if (route.usesPlayerSellerCustody) { CustodyDeferred def; CharacterDatabase.BeginTransaction(); @@ -1594,11 +1619,9 @@ void AuctionEntry::AuctionBidWinning(Player* newbidder) * RemoveAuction + `delete this`, so the in-memory auction survives until the very * end of def.run() (earlier closures that read auction fields snapshot by value). * - * Netting (Sec 5.4): the seller is paid bid + deposit - cut by the single legacy - * seller mail (step 1); the deposit + bid ledger rows are flipped LEDGER-ONLY - * (no second mail, no released coin). The deposit row "dep:" returns; the live - * bid row commits -- UNLESS bidder == 0 (bot-displaced win), where no bid row - * exists so the commit is skipped and the item destroys (winner guid 0). + * The seller payout and winner item delivery always retain legacy behavior. + * Seller item/deposit rows and the bidder row are terminalized independently, + * according to the route facts validated by the caller. * * Gold note: do NOT re-save newbidder's gold here. On a buyout it was already * saved by ReserveGold/TopUpBid in UpdateBidCustody; on the expiry path newbidder @@ -1608,6 +1631,8 @@ void AuctionEntry::AuctionBidWinning(Player* newbidder) * @param def Ordered deferred-effects queue for this co-commit. */ void AuctionEntry::AuctionBidWinningCustody(Player* newbidder, CustodyDeferred& def, + bool usesPlayerSellerCustody, + bool hasLiveBidCustody, std::string const& knownBidKey) { // (void) newbidder: its gold is already persisted by the bid seam (buyout) or @@ -1618,38 +1643,18 @@ void AuctionEntry::AuctionBidWinningCustody(Player* newbidder, CustodyDeferred& // deferred BEFORE the mail push by the co-commit core). sAuctionMgr.SendAuctionSuccessfulMailInTransaction(this, def); - // 2) Netting (Sec 5.4, ledger-only -- the seller mail above already carries - // both the deposit return and the proceeds, so flip the rows WITHOUT mail - // or coin to avoid double-crediting). - CustodyService::RollbackGoldLedgerOnly("dep:" + std::to_string(Id)); - if (bidder != 0) + // 2) Net player-seller custody only. The seller mail above already carries + // the deposit return and proceeds, so these are ledger-only transitions. + if (usesPlayerSellerCustody) { - // Commit the live bid row. A bot-displaced win (bidder == 0) carried no bid - // custody row -> skip (spec R2/X3). - if (!knownBidKey.empty()) - { - // Buyout path: the bid row was RESERVED in this same still-open txn, so - // a synchronous SELECT cannot see it yet -- use the key the bid seam - // just reserved. - CustodyService::CommitGoldLedgerOnly(knownBidKey); - } - else - { - // Expiry path: the bid row is committed -> fetch + validate it. - CustodyRow liveBidRow; - if (CustodyLedger::GetSingleLiveBidRow(Id, liveBidRow)) - { - CustodyService::CommitGoldLedgerOnly(liveBidRow.idemKey); - } - else - { - // Fail-soft: a real bidder with no single live bid row is a custody - // drift (logged for ah repair). The seller is still paid and the - // item still delivers; only the bid row's terminal flip is skipped. - sLog.outError("custody S4: no single live bid row for auction %u (bidder %u); " - "skipping bid commit", Id, bidder); - } - } + CustodyService::RollbackGoldLedgerOnly("dep:" + std::to_string(Id)); + } + + // The caller either validated this existing key before BeginTransaction or + // created it in this same transaction while processing a buyout. + if (hasLiveBidCustody) + { + CustodyService::CommitGoldLedgerOnly(knownBidKey); } // 3) Item to winner (receiver-exists owner UPDATE) or destroy (bidder == 0 -> @@ -1657,13 +1662,10 @@ void AuctionEntry::AuctionBidWinningCustody(Player* newbidder, CustodyDeferred& // (destroy: delete pItem) are deferred by the co-commit core. sAuctionMgr.SendAuctionWonMailInTransaction(this, def); - // Terminalize the item escrow row: on a win the item always resolves - // (delivered to the winner or destroyed by SendAuctionWonMailInTransaction - // above), so flip "item:" -> TERMINAL_OK ledger-only. In-txn, so on - // rollback the flip rolls back with everything else (no orphan). Without - // this the "item:" row stays CST_RESERVED after the auction row is deleted - // -> orphaned non-terminal row (breaks reconciliation / ah repair). - CustodyService::CommitGoldLedgerOnly("item:" + std::to_string(Id)); + if (usesPlayerSellerCustody) + { + CustodyService::CommitGoldLedgerOnly("item:" + std::to_string(Id)); + } // 4) Delete the auction row IN-TXN (appends to the caller's open transaction). this->DeleteFromDB(); @@ -1724,6 +1726,58 @@ void AuctionEntry::ExpireUnsoldCustody(CustodyDeferred& def) }); } +void AuctionEntry::PrepareCancelCustody(Player* seller, CustodyDeferred& def, + bool usesPlayerSellerCustody, + bool hasLiveBidCustody, + std::string const& liveBidKey, + uint32 auctionCut) +{ + if (bid) + { + seller->ModifyMoney(-int32(auctionCut)); + } + + if (bidder != 0) + { + if (hasLiveBidCustody) + { + CustodyService::RollbackGoldLedgerOnly(liveBidKey); + } + WorldSession::SendAuctionCancelledToBidderMailInTransaction(this, def); + } + + if (usesPlayerSellerCustody) + { + CustodyService::CommitGoldLedgerOnly("dep:" + std::to_string(Id)); + } + + Item* item = sAuctionMgr.GetAItem(itemGuidLow); + uint32 const savedItemGuidLow = itemGuidLow; + def.effects.push_back([savedItemGuidLow]() + { + sAuctionMgr.RemoveAItem(savedItemGuidLow); + }); + + std::ostringstream subject; + subject << itemTemplate << ":" << itemRandomPropertyId << ":" << AUCTION_CANCELED; + MailDraft itemReturn(subject.str(), ""); + itemReturn.AddItem(item); + if (usesPlayerSellerCustody) + { + CustodyService::DeliverItem(def, "item:" + std::to_string(Id), itemReturn, + MailReceiver(seller), MailSender(this), + MAIL_CHECK_MASK_COPIED); + } + else + { + itemReturn.SendMailToInTransaction(MailReceiver(seller), MailSender(this), + def, MAIL_CHECK_MASK_COPIED); + } + + seller->SaveInventoryAndGoldToDB(); + DeleteFromDB(); +} + /** * @brief Updates the current bid and handles buyout completion if reached. * @@ -1803,6 +1857,8 @@ bool AuctionEntry::UpdateBid(uint32 newbid, Player* newbidder /*=NULL*/) * @return true if the auction remains active (normal bid); false on buyout. */ bool AuctionEntry::UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDeferred& def, + bool usesPlayerSellerCustody, + bool hadLiveBidCustody, std::string const& liveBidKey) { // Cap the bid at buyout FIRST, mirroring UpdateBid (:1055-1058). A buyout bid @@ -1823,12 +1879,22 @@ bool AuctionEntry::UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDef if (newbidder && newbidder->GetGUIDLow() == bidder) { - // same-bidder raise: debit the DELTA and bump the live bid row amount. - // The full-price affordability guard in the handler already gated this - // (spec I1). Mirrors UpdateBid's ModifyMoney(-(newbid - bid)). The live - // bid key was pre-fetched and VALIDATED by the handler (spec I1). - CustodyService::TopUpBid(liveBidKey, newbid, newbid - bid, newbidder); - winningBidKey = liveBidKey; + if (hadLiveBidCustody) + { + CustodyService::TopUpBid(liveBidKey, newbid, newbid - bid, newbidder); + winningBidKey = liveBidKey; + } + else + { + // Seller custody can meet a legacy standing bid. Preserve the + // legacy delta debit, then establish custody at the full new amount. + newbidder->ModifyMoney(-int32(newbid - bid)); + newbidder->SaveInventoryAndGoldToDB(); + winningBidKey = "bid:" + std::to_string(Id) + ":" + + std::to_string(CustodyLedger::NextBidSeq(Id)); + CustodyService::ReserveGoldAlreadyDebited( + newbidder->GetGUIDLow(), newbid, winningBidKey, Id, ROLE_BID); + } } else { @@ -1838,10 +1904,10 @@ bool AuctionEntry::UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDef // (matches UpdateBid's `if (bidder)` skipping the refund -- spec R2). if (bidder != 0) { - // liveBidKey was pre-fetched and VALIDATED by the handler (owner_guid - // == bidder, amount == bid, exactly one live row) before the txn - // opened (spec I1), so terminalize exactly that verified row. - CustodyService::RollbackGoldLedgerOnly(liveBidKey); + if (hadLiveBidCustody) + { + CustodyService::RollbackGoldLedgerOnly(liveBidKey); + } WorldSession::SendAuctionOutbiddedMailInTransaction(this, def); } @@ -1875,7 +1941,8 @@ bool AuctionEntry::UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDef // SELECT for the uncommitted row (bidder==0 cannot happen here: a player buyout // always has a live newbidder). The auction is deleted in a deferred closure // run only after the caller's checked commit succeeds. - AuctionBidWinningCustody(newbidder, def, winningBidKey); + AuctionBidWinningCustody(newbidder, def, usesPlayerSellerCustody, + true, winningBidKey); return false; } diff --git a/src/game/Object/AuctionHouseMgr.h b/src/game/Object/AuctionHouseMgr.h index c7d06fecc..533ea291d 100644 --- a/src/game/Object/AuctionHouseMgr.h +++ b/src/game/Object/AuctionHouseMgr.h @@ -121,13 +121,13 @@ struct AuctionEntry /// caller checked-commits it then runs @p def (spec Sec 6 S4). @p newbidder is /// the online winner (buyout path) or NULL (expiry path). /// - /// @p knownBidKey is the idem_key of the live bid row to commit-net. It is - /// REQUIRED on the buyout path: the bid row was just RESERVED in the same - /// still-open transaction, so a synchronous SELECT (GetSingleLiveBidRow) cannot - /// see it yet -- the caller passes the key it just reserved. On the expiry path - /// the bid row is already committed, so the caller passes "" and this method - /// re-fetches + validates it via GetSingleLiveBidRow. Ignored when bidder == 0. + /// Seller custody and bid custody are independent: only seller custody + /// terminalizes item/deposit rows, and only bid custody terminalizes + /// @p knownBidKey. The caller validates any existing live bid row before + /// opening the transaction. void AuctionBidWinningCustody(Player* newbidder, CustodyDeferred& def, + bool usesPlayerSellerCustody, + bool hasLiveBidCustody, std::string const& knownBidKey = ""); /// Custody co-commit mirror of the unsold-expiry path (spec S6): returns the /// item to the seller by mail (or destroys it if the account is gone), @@ -138,6 +138,14 @@ struct AuctionEntry /// checked-commits it then runs @p def. S6 makes NO synchronous in-memory /// mutation, so on rollback there is nothing to restore. void ExpireUnsoldCustody(CustodyDeferred& def); + /// Append the cancel value transaction to the caller's open transaction. + /// The handler retains affordability checks, checked commit/rollback, + /// command results, cache removal, Eluna notification, and object deletion. + void PrepareCancelCustody(Player* seller, CustodyDeferred& def, + bool usesPlayerSellerCustody, + bool hasLiveBidCustody, + std::string const& liveBidKey, + uint32 auctionCut); bool UpdateBid(uint32 newbid, Player* newbidder = NULL);// true if normal bid, false if buyout, bidder==NULL for generated bid /// Custody co-commit mirror of UpdateBid: moves the bidder's gold via the /// custody primitives and appends every DB write to the caller's already-open @@ -146,11 +154,10 @@ struct AuctionEntry /// mail is sent via the static SendAuctionOutbiddedMailInTransaction, which /// resolves the old bidder's session itself, so no acting session is needed. /// - /// @p liveBidKey is the idem_key of the existing live bid row, pre-fetched and - /// VALIDATED by the handler before BeginTransaction (spec I1) for the - /// same-bidder raise and the outbid-displacement cases; it is empty when the - /// auction has no live bid row (bidder==0 / first bid). Used directly so this - /// method never re-looks-up (and never trusts) an unvalidated row. + /// @p hadLiveBidCustody says whether @p liveBidKey identifies the validated + /// existing bid row. Seller custody can route here without such a row; in + /// that case prior-bid refunds retain legacy behavior and the replacement + /// bid starts custody from its full current amount. /// /// A buyout (newbid >= buyout) is absorbed here (Task 10): the bid is capped at /// buyout, reserved/refunded as a normal bid, then the win resolves on the same @@ -158,6 +165,8 @@ struct AuctionEntry /// mutations deferred into @p def). Returns true if the auction remains active /// (normal bid), false on a buyout (auction resolved + scheduled for delete). bool UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDeferred& def, + bool usesPlayerSellerCustody, + bool hadLiveBidCustody, std::string const& liveBidKey); }; diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index 661a7f259..0f48fcff2 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -1105,10 +1105,15 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recv_data) newOutbid = 1; } - // Buyout now goes through custody too (Task 10): UpdateBidCustody caps the - // bid at buyout, reserves/refunds as a normal bid, then routes to - // AuctionBidWinningCustody, all on the handler's single open transaction. - if (sWorld.IsAhCustodyEnabled() && CustodyLedger::HasRows(auction->Id)) + CustodyRouteState route = {}; + if (sWorld.IsAhCustodyEnabled()) + { + route = CustodyLedger::GetRouteState(auction->Id); + } + + // A player-seller row or a player-bid row selects the combined transaction. + // Marker-only bot listings remain entirely on the legacy path. + if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) { // Custody co-commit path. The success-path SendAuctionCommandResult is // deferred and appended FIRST (its legacy position :507 precedes @@ -1116,15 +1121,15 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recv_data) // buyout route deletes the AuctionEntry in the same deferred run (I5). uint32 const capId = auction->Id; - // FIX I1: validated, fail-closed live-bid lookup BEFORE the txn (it is a - // read; no live mutation has happened yet). When the auction already has - // a bidder (same-bidder raise OR outbid), there MUST be exactly one live - // bid row matching the current auction state; otherwise fail closed. + // A route that claims live bid custody must have exactly one row matching + // the current auction state. Seller custody may legitimately coexist with + // a legacy standing bid and therefore does not imply this validation. std::string liveBidKey; - if (auction->bidder != 0) + if (route.hasLiveBidCustody) { CustodyRow liveRow; - if (!CustodyLedger::GetSingleLiveBidRow(capId, liveRow) || + if (auction->bidder == 0 || + !CustodyLedger::GetSingleLiveBidRow(capId, liveRow) || liveRow.ownerGuid != auction->bidder || liveRow.amount != auction->bid) { @@ -1171,7 +1176,9 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recv_data) // paths -> proceed to the checked commit. On the buyout path the auction is // NOT deleted until def.run(), so the X6 restore below is still safe to // reference auction on a commit FAILURE (def.run() did not execute). - bool const stillActive = auction->UpdateBidCustody(price, pl, def, liveBidKey); + bool const stillActive = auction->UpdateBidCustody( + price, pl, def, route.usesPlayerSellerCustody, + route.hasLiveBidCustody, liveBidKey); (void)stillActive; CustodyService::MaybeCrash("pre-commit"); @@ -1287,7 +1294,13 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) return; } - if (sWorld.IsAhCustodyEnabled() && CustodyLedger::HasRows(auction->Id)) + CustodyRouteState route = {}; + if (sWorld.IsAhCustodyEnabled()) + { + route = CustodyLedger::GetRouteState(auction->Id); + } + + if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) { // ------------------------------------------------------------------- // Custody co-commit path (per-auction drain, X3). One checked txn: @@ -1306,17 +1319,15 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) } uint32 const capId = auction->Id; - uint32 const itemGuidLow = auction->itemGuidLow; - // (2) I1 fail-closed live-bid lookup BEFORE the txn (it is a read; no - // mutation yet). When the auction has a real bidder there MUST be - // exactly one live bid row matching the current auction state; - // otherwise fail closed (no txn, no mutation). + // A bid-custody route must match exactly one current live bid row. + // Seller custody alone may legitimately contain a legacy standing bid. std::string liveBidKey; - if (auction->bidder != 0) + if (route.hasLiveBidCustody) { CustodyRow liveRow; - if (!CustodyLedger::GetSingleLiveBidRow(capId, liveRow) || + if (auction->bidder == 0 || + !CustodyLedger::GetSingleLiveBidRow(capId, liveRow) || liveRow.ownerGuid != auction->bidder || liveRow.amount != auction->bid) { @@ -1328,10 +1339,7 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) liveBidKey = liveRow.idemKey; } - // (3) X6 snapshot. The ONLY synchronous in-memory mutation S5 makes is - // the cut debit; capture it for restore-on-failure. (cutDebited == 0 - // when there is no bid -- nothing to restore in that case.) - uint32 const cutDebited = auctionCut; + uint32 const cutDebited = auction->bid ? auctionCut : 0; // Capture the seller's low GUID so the deferred command-result closure // holds only uint32 scalars; re-resolving at run-time avoids a dangling @@ -1339,50 +1347,10 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) uint32 const sellerGuidLow = pl->GetGUIDLow(); CustodyDeferred def; - - // -- in-memory mutation BEFORE the txn (SaveInventoryAndGoldToDB persists - // current memory) -- - if (auction->bid) - { - pl->ModifyMoney(-int32(auctionCut)); - } - CharacterDatabase.BeginTransaction(); - - // (a) Bidder refund: pushes the removed-notify THEN the refund-mail push - // into def (legacy notify-before-mail, :334-339). Only for a REAL - // bidder; a bot-bid auction (bidder==0) charges the cut but sends no - // refund (spec R2). Terminalize the validated live bid row (ledger - // only -- the refund coin rides the mail above). - if (auction->bid && auction->bidder != 0) - { - CustodyService::RollbackGoldLedgerOnly(liveBidKey); - SendAuctionCancelledToBidderMailInTransaction(auction, def); - } - - // Deposit FORFEIT to the house on cancel (spec 4.2 / S5): flip the - // deposit row to TERMINAL_OK ledger-only -- house sink, no money, no mail. - CustodyService::CommitGoldLedgerOnly("dep:" + std::to_string(capId)); - - // (b) Seller item-return. Build the return mail exactly like legacy - // (:848-854) and co-commit it. Push the seam RemoveAItem FIRST so it - // runs BEFORE the item-mail's disposal closure (RemoveAItem-first, - // the corrected lifecycle); DeliverItem appends the mail push (and the - // online seller's AddMItem disposal) AFTER. On rollback neither runs - // and the item survives in mAitems for re-resolution. - std::ostringstream msgAuctionCanceledOwner; - msgAuctionCanceledOwner << auction->itemTemplate << ":" << auction->itemRandomPropertyId << ":" << AUCTION_CANCELED; - - def.effects.push_back([itemGuidLow]() - { - sAuctionMgr.RemoveAItem(itemGuidLow); - }); - - MailDraft itemReturn(msgAuctionCanceledOwner.str(), ""); - itemReturn.AddItem(pItem); - CustodyService::DeliverItem(def, "item:" + std::to_string(capId), itemReturn, - MailReceiver(pl), MailSender(auction), - MAIL_CHECK_MASK_COPIED); + auction->PrepareCancelCustody( + pl, def, route.usesPlayerSellerCustody, + route.hasLiveBidCustody, liveBidKey, auctionCut); // (c) Command-result to the SELLER, deferred LAST (legacy :857 fires it // after the item mail). Scalar-only closure: re-resolve the seller by @@ -1397,10 +1365,6 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) } }); - // Persist the cut debit + delete the auction row, both IN-TXN. - pl->SaveInventoryAndGoldToDB(); - auction->DeleteFromDB(); - // Defer the AH-map erase + Eluna OnRemove hook + object delete LAST, in // the exact legacy order of the non-custody branch (RemoveAuction // out-of-map -> OnRemove -> delete), so OnRemove fires ONLY on a diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index e37fcf8dc..0e0d2ac80 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -23,6 +23,7 @@ #include "Item.h" #include "BrowseMessages.h" #include "World.h" +#include "WorldSession.h" #include "PlayerMutations.h" #include #include @@ -779,7 +780,7 @@ static int RunMailTest() return 2; } -/// CRUD round-trip test for CustodyLedger: Insert, Get, HasRows, SetState, +/// CRUD round-trip test for CustodyLedger: Insert, Get, GetRouteState, SetState, /// LoadNonTerminal, DeleteTerminalOlderThan. Returns 0 on pass. static int RunCustodyTest() { @@ -844,15 +845,17 @@ static int RunCustodyTest() } } - // HasRows(999) must be true; HasRows(424242) must be false. - if (!CustodyLedger::HasRows(999)) + // The seeded bid row selects only bid custody; an absent auction selects none. + CustodyRouteState const seededRoute = CustodyLedger::GetRouteState(999); + if (seededRoute.usesPlayerSellerCustody || !seededRoute.hasLiveBidCustody) { - printf("custody FAIL: step 2 HasRows(999) returned false\n"); + printf("custody FAIL: step 2 GetRouteState(999) mismatch\n"); pass = false; } - if (CustodyLedger::HasRows(424242)) + CustodyRouteState const absentRoute = CustodyLedger::GetRouteState(424242); + if (absentRoute.usesPlayerSellerCustody || absentRoute.hasLiveBidCustody) { - printf("custody FAIL: step 2 HasRows(424242) returned true (unexpected)\n"); + printf("custody FAIL: step 2 GetRouteState(424242) unexpectedly routed\n"); pass = false; } @@ -3561,6 +3564,707 @@ static int RunAhResolveTest() return 2; } +/// Route and conservation regressions for seller custody and bid custody as +/// independent dimensions. Uses only disposable Character DB fixtures. +static int RunAhCustodyRouteTest() +{ + bool pass = true; + CharacterDatabase.AllowAsyncTransactions(); + sObjectMgr.LoadItemPrototypes(); + + uint32 itemId = 2589u; + if (!ObjectMgr::GetItemPrototype(itemId)) + { + std::unique_ptr result(WorldDatabase.Query( + "SELECT `entry` FROM `item_template` " + "WHERE `InventoryType`=0 AND `stackable`>1 ORDER BY `entry` LIMIT 1")); + if (result) + { + itemId = result->Fetch()[0].GetUInt32(); + } + } + if (!ObjectMgr::GetItemPrototype(itemId)) + { + printf("ahcustodyroute FAIL: no usable item prototype\n"); + return 2; + } + + uint32 const sellerGuid = 9501u; + uint32 const bidderGuid = 9502u; + uint32 const otherBidderGuid = 9503u; + uint64 const now = static_cast(time(NULL)); + uint64 const oldTime = now > 7200u ? now - 7200u : 1u; + std::vector itemGuids; + + CharacterDatabase.DirectExecute( + "DELETE FROM `mail_items` WHERE `receiver` IN (9501,9502,9503)"); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver` IN (9501,9502,9503)"); + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id` BETWEEN 995100 AND 995199"); + CharacterDatabase.DirectExecute( + "DELETE FROM `auction` WHERE `id` BETWEEN 995100 AND 995199"); + CharacterDatabase.DirectExecute( + "DELETE FROM `item_instance` WHERE `owner_guid` IN (9501,9502,9503)"); + CharacterDatabase.DirectExecute( + "DELETE FROM `characters` WHERE `guid` IN (9501,9502,9503)"); + CharacterDatabase.DirectExecute( + "INSERT INTO `characters` (`guid`,`account`,`name`,`money`) VALUES " + "(9501,9501,'AhRtSeller',1000)," + "(9502,9502,'AhRtBidder',1000)," + "(9503,9503,'AhRtOther',1000)"); + sObjectMgr.SetHighestGuids(); + + AuctionHouseEntry house = {}; + house.houseId = 7; + house.faction = 0; + house.depositPercent = 5; + house.cutPercent = 5; + + auto seedRow = [oldTime](std::string const& key, uint8 kind, uint8 role, + uint32 owner, uint32 amount, uint32 itemGuid, + uint32 auctionId) + { + CharacterDatabase.DirectPExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('%s',%u,%u,0,%u,0,%u,%u,%u," UI64FMTD ",0)", + key.c_str(), uint32(kind), uint32(role), owner, amount, + itemGuid, auctionId, oldTime); + }; + + auto createAuction = [&](uint32 auctionId, uint32 owner, uint32 bidder, + uint32 bid, uint32 buyout, + uint32 deposit) -> AuctionEntry* + { + Item* item = Item::CreateItem(itemId, 1); + if (!item) + { + return NULL; + } + item->SetOwnerGuid(ObjectGuid(HIGHGUID_PLAYER, owner)); + itemGuids.push_back(item->GetGUIDLow()); + + AuctionEntry* auction = new AuctionEntry(); + auction->Id = auctionId; + auction->itemGuidLow = item->GetGUIDLow(); + auction->itemTemplate = itemId; + auction->itemCount = 1; + auction->itemRandomPropertyId = 0; + auction->owner = owner; + auction->startbid = 10; + auction->bid = bid; + auction->buyout = buyout; + auction->expireTime = static_cast(now + HOUR); + auction->bidder = bidder; + auction->deposit = deposit; + auction->auctionHouseEntry = &house; + + CharacterDatabase.BeginTransaction(); + item->SaveToDB(); + auction->SaveToDB(); + if (!CharacterDatabase.CommitTransactionChecked()) + { + delete item; + delete auction; + return NULL; + } + sAuctionMgr.AddAItem(item); + return auction; + }; + + auto rowState = [](std::string const& key) -> uint32 + { + CustodyRow row; + return CustodyLedger::Get(key, row) ? uint32(row.state) : 255u; + }; + auto auctionExists = [](uint32 auctionId) -> bool + { + return CustodyLedger::AuctionExists(auctionId); + }; + auto mailCount = [itemId](uint32 receiver, MailAuctionAnswers answer) -> uint32 + { + std::unique_ptr result(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `mail` WHERE `receiver`=%u " + "AND `subject`='%u:0:%u'", receiver, itemId, uint32(answer))); + return result ? result->Fetch()[0].GetUInt32() : 0u; + }; + auto mailMoney = [itemId](uint32 receiver, MailAuctionAnswers answer) -> uint64 + { + std::unique_ptr result(CharacterDatabase.PQuery( + "SELECT COALESCE(SUM(`money`),0) FROM `mail` WHERE `receiver`=%u " + "AND `subject`='%u:0:%u'", receiver, itemId, uint32(answer))); + return result ? result->Fetch()[0].GetUInt64() : 0u; + }; + auto itemMailCount = [](uint32 receiver, uint32 itemGuid) -> uint32 + { + std::unique_ptr result(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `mail_items` WHERE `receiver`=%u AND `item_guid`=%u", + receiver, itemGuid)); + return result ? result->Fetch()[0].GetUInt32() : 0u; + }; + auto characterMoney = [](uint32 guid) -> uint32 + { + std::unique_ptr result(CharacterDatabase.PQuery( + "SELECT `money` FROM `characters` WHERE `guid`=%u", guid)); + return result ? result->Fetch()[0].GetUInt32() : 0u; + }; + auto sellerRows = [](uint32 auctionId) -> uint32 + { + std::unique_ptr result(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `custody_ledger` WHERE `auction_id`=%u " + "AND `state`=0 AND (`idem_key` IN ('item:%u','dep:%u') " + "OR `role` IN (%u,%u))", + auctionId, auctionId, auctionId, + uint32(ROLE_ITEM), uint32(ROLE_DEPOSIT))); + return result ? result->Fetch()[0].GetUInt32() : 0u; + }; + auto clearFixtureMail = []() + { + CharacterDatabase.DirectExecute( + "DELETE FROM `mail_items` WHERE `receiver` IN (9501,9502,9503)"); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver` IN (9501,9502,9503)"); + }; + + // Exact runtime route matrix, including the seller-only unsold-expiry fact. + seedRow("item:995101", CUSTODY_ITEM, ROLE_ITEM, + sellerGuid, 0, 1, 995101); + seedRow("dep:995101", CUSTODY_GOLD, ROLE_DEPOSIT, + sellerGuid, 5, 0, 995101); + seedRow("bid:995102:1", CUSTODY_GOLD, ROLE_BID, + bidderGuid, 20, 0, 995102); + seedRow("resolve:test:995103", CUSTODY_GOLD, ROLE_RESOLUTION, + 0, 0, 0, 995103); + seedRow("botlist:test:995104", CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, 4, 995104); + seedRow("botlist:test:995105", CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, 5, 995105); + seedRow("bid:995105:1", CUSTODY_GOLD, ROLE_BID, + bidderGuid, 50, 0, 995105); + seedRow("botlist:test:995106", CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, 6, 995106); + seedRow("item:995106", CUSTODY_ITEM, ROLE_ITEM, + AHBOT_SYSTEM_OWNER_GUID, 0, 6, 995106); + + struct RouteExpectation + { + uint32 auctionId; + bool seller; + bool bid; + }; + RouteExpectation const routeExpectations[] = { + { 995101, true, false }, + { 995102, false, true }, + { 995103, false, false }, + { 995104, false, false }, + { 995105, false, true }, + { 995106, false, false }, + }; + for (size_t i = 0; i < sizeof(routeExpectations) / sizeof(routeExpectations[0]); ++i) + { + CustodyRouteState const route = + CustodyLedger::GetRouteState(routeExpectations[i].auctionId); + if (route.usesPlayerSellerCustody != routeExpectations[i].seller || + route.hasLiveBidCustody != routeExpectations[i].bid) + { + printf("ahcustodyroute FAIL: route matrix mismatch auction=%u\n", + routeExpectations[i].auctionId); + pass = false; + } + } + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id` BETWEEN 995100 AND 995109"); + + // Marker-only first bid remains on the legacy path and creates no bid or + // seller custody while debiting/persisting exactly once. + { + uint32 const auctionId = 995110; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, 0, 0, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: legacy first-bid fixture creation\n"); + pass = false; + } + else + { + seedRow("botlist:test:995110", CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, auction->itemGuidLow, auctionId); + CustodyRouteState const route = CustodyLedger::GetRouteState(auctionId); + WorldSession session(9502, std::shared_ptr(), + std::shared_ptr(), SEC_PLAYER, + 0, LOCALE_enUS); + Player bidder(&session); + session.SetPlayer(&bidder); + bidder._Create(bidderGuid, HIGHGUID_PLAYER); + bidder.SetMoney(1000); + bool const active = auction->UpdateBid(100, &bidder); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.CommitTransactionChecked(); + + std::unique_ptr persisted(CharacterDatabase.PQuery( + "SELECT `buyguid`,`lastbid` FROM `auction` WHERE `id`=%u", + auctionId)); + std::unique_ptr bidRows(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `custody_ledger` WHERE `auction_id`=%u " + "AND `state`=0 AND `role`=%u", + auctionId, uint32(ROLE_BID))); + if (route.usesPlayerSellerCustody || route.hasLiveBidCustody || + !active || bidder.GetMoney() != 900 || + characterMoney(bidderGuid) != 900 || !persisted || + persisted->Fetch()[0].GetUInt32() != bidderGuid || + persisted->Fetch()[1].GetUInt32() != 100 || !bidRows || + bidRows->Fetch()[0].GetUInt32() != 0 || + rowState("botlist:test:995110") != CST_RESERVED) + { + printf("ahcustodyroute FAIL: marker-only first bid left legacy behavior\n"); + pass = false; + } + session.SetPlayer(NULL); + + Item* liveItem = sAuctionMgr.GetAItem(auction->itemGuidLow); + sAuctionMgr.RemoveAItem(auction->itemGuidLow); + delete liveItem; + delete auction; + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", auctionId); + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + } + } + + // Bid-winning expiry on a bot listing: seller and winner settlement remains + // legacy-equivalent, while only the player bid row is terminalized. + { + clearFixtureMail(); + uint32 const auctionId = 995120; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, bidderGuid, 100, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: bid-only win fixture creation\n"); + pass = false; + } + else + { + uint32 const itemGuid = auction->itemGuidLow; + std::string const markerKey = "botlist:test:995120"; + std::string const bidKey = "bid:995120:1"; + seedRow(markerKey, CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, itemGuid, auctionId); + seedRow(bidKey, CUSTODY_GOLD, ROLE_BID, + bidderGuid, 100, 0, auctionId); + CustodyRouteState const route = CustodyLedger::GetRouteState(auctionId); + CustodyDeferred def; + CharacterDatabase.BeginTransaction(); + auction->AuctionBidWinningCustody(NULL, def, + route.usesPlayerSellerCustody, + route.hasLiveBidCustody, bidKey); + bool const committed = CharacterDatabase.CommitTransactionChecked(); + if (committed) + { + def.run(); + } + if (!committed || route.usesPlayerSellerCustody || + !route.hasLiveBidCustody || auctionExists(auctionId) || + rowState(bidKey) != CST_TERMINAL_OK || + rowState(markerKey) != CST_RESERVED || sellerRows(auctionId) != 0 || + mailCount(sellerGuid, AUCTION_SUCCESSFUL) != 1 || + mailMoney(sellerGuid, AUCTION_SUCCESSFUL) == 0 || + mailCount(bidderGuid, AUCTION_WON) != 1 || + itemMailCount(bidderGuid, itemGuid) != 1) + { + printf("ahcustodyroute FAIL: bid-only winning expiry conservation\n"); + pass = false; + } + } + } + + // Same-bidder buyout preserves bot provenance, debits only the delta, and + // terminalizes the existing bid row without inventing seller rows. + { + clearFixtureMail(); + uint32 const auctionId = 995121; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, bidderGuid, 40, 100, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: buyout fixture creation\n"); + pass = false; + } + else + { + uint32 const itemGuid = auction->itemGuidLow; + std::string const markerKey = "botlist:test:995121"; + std::string const bidKey = "bid:995121:1"; + seedRow(markerKey, CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, itemGuid, auctionId); + seedRow(bidKey, CUSTODY_GOLD, ROLE_BID, + bidderGuid, 40, 0, auctionId); + + CharacterDatabase.DirectPExecute( + "UPDATE `characters` SET `money`=1000 WHERE `guid`=%u", + bidderGuid); + WorldSession session(9502, std::shared_ptr(), + std::shared_ptr(), SEC_PLAYER, + 0, LOCALE_enUS); + Player bidder(&session); + session.SetPlayer(&bidder); + bidder._Create(bidderGuid, HIGHGUID_PLAYER); + bidder.SetMoney(1000); + + CustodyDeferred def; + CharacterDatabase.BeginTransaction(); + bool const active = auction->UpdateBidCustody( + 100, &bidder, def, false, true, bidKey); + bool const committed = CharacterDatabase.CommitTransactionChecked(); + if (committed) + { + def.run(); + } + if (!committed || active || bidder.GetMoney() != 940 || + characterMoney(bidderGuid) != 940 || auctionExists(auctionId) || + rowState(bidKey) != CST_TERMINAL_OK || + rowState(markerKey) != CST_RESERVED || sellerRows(auctionId) != 0 || + mailCount(sellerGuid, AUCTION_SUCCESSFUL) != 1 || + itemMailCount(bidderGuid, itemGuid) != 1) + { + printf("ahcustodyroute FAIL: bid-only buyout conservation\n"); + pass = false; + } + session.SetPlayer(NULL); + } + } + + // Owner cancel on a bot listing refunds the bidder and returns the item, + // while only bid custody transitions and the bot marker remains reserved. + { + clearFixtureMail(); + uint32 const auctionId = 995122; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, otherBidderGuid, 100, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: cancel fixture creation\n"); + pass = false; + } + else + { + uint32 const itemGuid = auction->itemGuidLow; + std::string const markerKey = "botlist:test:995122"; + std::string const bidKey = "bid:995122:1"; + seedRow(markerKey, CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, itemGuid, auctionId); + seedRow(bidKey, CUSTODY_GOLD, ROLE_BID, + otherBidderGuid, 100, 0, auctionId); + + CharacterDatabase.DirectPExecute( + "UPDATE `characters` SET `money`=1000 WHERE `guid`=%u", + sellerGuid); + WorldSession session(9501, std::shared_ptr(), + std::shared_ptr(), SEC_PLAYER, + 0, LOCALE_enUS); + Player seller(&session); + session.SetPlayer(&seller); + seller._Create(sellerGuid, HIGHGUID_PLAYER); + seller.SetMoney(1000); + uint32 const cut = auction->GetAuctionCut(); + + CustodyDeferred def; + CharacterDatabase.BeginTransaction(); + auction->PrepareCancelCustody(&seller, def, false, true, + bidKey, cut); + bool const committed = CharacterDatabase.CommitTransactionChecked(); + if (committed) + { + def.run(); + } + if (!committed || auctionExists(auctionId) || + seller.GetMoney() != 1000 - cut || + characterMoney(sellerGuid) != 1000 - cut || + rowState(bidKey) != CST_TERMINAL_BACK || + rowState(markerKey) != CST_RESERVED || sellerRows(auctionId) != 0 || + mailCount(otherBidderGuid, AUCTION_CANCELLED_TO_BIDDER) != 1 || + mailMoney(otherBidderGuid, AUCTION_CANCELLED_TO_BIDDER) != 100 || + mailCount(sellerGuid, AUCTION_CANCELED) != 1 || + itemMailCount(sellerGuid, itemGuid) != 1) + { + printf("ahcustodyroute FAIL: bid-only cancel conservation\n"); + pass = false; + } + session.SetPlayer(NULL); + delete auction; + } + } + + // A same-bidder raise can meet player-seller custody layered over a legacy + // bid. Debit only the delta, then represent the full standing bid in one row. + { + clearFixtureMail(); + uint32 const auctionId = 995124; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, bidderGuid, 40, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: legacy same-bid fixture creation\n"); + pass = false; + } + else + { + std::string const itemKey = "item:995124"; + std::string const depKey = "dep:995124"; + seedRow(itemKey, CUSTODY_ITEM, ROLE_ITEM, + sellerGuid, 0, auction->itemGuidLow, auctionId); + seedRow(depKey, CUSTODY_GOLD, ROLE_DEPOSIT, + sellerGuid, 20, 0, auctionId); + + CharacterDatabase.DirectPExecute( + "UPDATE `characters` SET `money`=1000 WHERE `guid`=%u", + bidderGuid); + WorldSession session(9502, std::shared_ptr(), + std::shared_ptr(), SEC_PLAYER, + 0, LOCALE_enUS); + Player bidder(&session); + session.SetPlayer(&bidder); + bidder._Create(bidderGuid, HIGHGUID_PLAYER); + bidder.SetMoney(1000); + + CustodyRouteState const route = CustodyLedger::GetRouteState(auctionId); + CustodyDeferred def; + CharacterDatabase.BeginTransaction(); + bool const active = auction->UpdateBidCustody( + 60, &bidder, def, route.usesPlayerSellerCustody, + route.hasLiveBidCustody, ""); + bool const committed = CharacterDatabase.CommitTransactionChecked(); + if (committed) + { + def.run(); + } + + std::unique_ptr bidRows(CharacterDatabase.PQuery( + "SELECT COUNT(*),COALESCE(MAX(`owner_guid`),0)," + "COALESCE(MAX(`amount`),0) FROM `custody_ledger` " + "WHERE `auction_id`=%u AND `state`=0 AND `role`=%u", + auctionId, uint32(ROLE_BID))); + Field* bidFields = bidRows ? bidRows->Fetch() : NULL; + if (!committed || !active || !route.usesPlayerSellerCustody || + route.hasLiveBidCustody || bidder.GetMoney() != 980 || + characterMoney(bidderGuid) != 980 || !auctionExists(auctionId) || + auction->bidder != bidderGuid || auction->bid != 60 || + !bidFields || bidFields[0].GetUInt32() != 1 || + bidFields[1].GetUInt32() != bidderGuid || + bidFields[2].GetUInt32() != 60 || + rowState(itemKey) != CST_RESERVED || + rowState(depKey) != CST_RESERVED) + { + printf("ahcustodyroute FAIL: legacy same-bid custody promotion\n"); + pass = false; + } + session.SetPlayer(NULL); + delete auction; + } + } + + // Replacing a legacy standing bid under player-seller custody preserves the + // old bidder's mail refund and starts custody only for the replacement bid. + { + clearFixtureMail(); + uint32 const auctionId = 995125; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, otherBidderGuid, 40, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: legacy outbid fixture creation\n"); + pass = false; + } + else + { + std::string const itemKey = "item:995125"; + std::string const depKey = "dep:995125"; + seedRow(itemKey, CUSTODY_ITEM, ROLE_ITEM, + sellerGuid, 0, auction->itemGuidLow, auctionId); + seedRow(depKey, CUSTODY_GOLD, ROLE_DEPOSIT, + sellerGuid, 20, 0, auctionId); + + CharacterDatabase.DirectPExecute( + "UPDATE `characters` SET `money`=1000 WHERE `guid`=%u", + bidderGuid); + WorldSession session(9502, std::shared_ptr(), + std::shared_ptr(), SEC_PLAYER, + 0, LOCALE_enUS); + Player bidder(&session); + session.SetPlayer(&bidder); + bidder._Create(bidderGuid, HIGHGUID_PLAYER); + bidder.SetMoney(1000); + + CustodyRouteState const route = CustodyLedger::GetRouteState(auctionId); + CustodyDeferred def; + CharacterDatabase.BeginTransaction(); + bool const active = auction->UpdateBidCustody( + 60, &bidder, def, route.usesPlayerSellerCustody, + route.hasLiveBidCustody, ""); + bool const committed = CharacterDatabase.CommitTransactionChecked(); + if (committed) + { + def.run(); + } + + std::unique_ptr bidRows(CharacterDatabase.PQuery( + "SELECT COUNT(*),COALESCE(MAX(`owner_guid`),0)," + "COALESCE(MAX(`amount`),0) FROM `custody_ledger` " + "WHERE `auction_id`=%u AND `state`=0 AND `role`=%u", + auctionId, uint32(ROLE_BID))); + Field* bidFields = bidRows ? bidRows->Fetch() : NULL; + if (!committed || !active || !route.usesPlayerSellerCustody || + route.hasLiveBidCustody || bidder.GetMoney() != 940 || + characterMoney(bidderGuid) != 940 || !auctionExists(auctionId) || + !bidFields || bidFields[0].GetUInt32() != 1 || + bidFields[1].GetUInt32() != bidderGuid || + bidFields[2].GetUInt32() != 60 || + mailCount(otherBidderGuid, AUCTION_OUTBIDDED) != 1 || + mailMoney(otherBidderGuid, AUCTION_OUTBIDDED) != 40 || + rowState(itemKey) != CST_RESERVED || + rowState(depKey) != CST_RESERVED) + { + printf("ahcustodyroute FAIL: legacy bidder replacement\n"); + pass = false; + } + session.SetPlayer(NULL); + delete auction; + } + } + + // Bid-only custody never selects the unsold custody path. Even malformed + // no-bid book facts retain the legacy item return and leave bid provenance. + { + clearFixtureMail(); + uint32 const auctionId = 995126; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, 0, 0, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: bid-only unsold fixture creation\n"); + pass = false; + } + else + { + uint32 const itemGuid = auction->itemGuidLow; + std::string const markerKey = "botlist:test:995126"; + std::string const bidKey = "bid:995126:1"; + seedRow(markerKey, CUSTODY_ITEM, ROLE_RESOLUTION, + AHBOT_SYSTEM_OWNER_GUID, 0, itemGuid, auctionId); + seedRow(bidKey, CUSTODY_GOLD, ROLE_BID, + bidderGuid, 50, 0, auctionId); + CustodyRouteState const route = CustodyLedger::GetRouteState(auctionId); + auction->expireTime = static_cast(-1); + AuctionHouseObject* houseMap = sAuctionMgr.GetAuctionsMap(&house); + houseMap->AddAuction(auction); + houseMap->Update(); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.CommitTransactionChecked(); + + bool const mapPresent = houseMap->GetAuction(auctionId) != NULL; + bool const dbPresent = auctionExists(auctionId); + uint32 const bidState = rowState(bidKey); + uint32 const markerState = rowState(markerKey); + uint32 const sellerRowCount = sellerRows(auctionId); + uint32 const expiredMailCount = mailCount(sellerGuid, AUCTION_EXPIRED); + uint32 const expiredItemCount = itemMailCount(sellerGuid, itemGuid); + if (route.usesPlayerSellerCustody || !route.hasLiveBidCustody || + mapPresent || dbPresent || bidState != CST_RESERVED || + markerState != CST_RESERVED || sellerRowCount != 0 || + expiredMailCount != 1 || expiredItemCount != 1) + { + printf("ahcustodyroute FAIL: bid-only unsold routing " + "route=%u/%u map=%u db=%u states=%u/%u seller=%u mail=%u/%u\n", + uint32(route.usesPlayerSellerCustody), + uint32(route.hasLiveBidCustody), uint32(mapPresent), + uint32(dbPresent), bidState, markerState, + sellerRowCount, expiredMailCount, expiredItemCount); + pass = false; + } + } + } + + // Existing full player seller+bid custody still terminalizes all three + // value rows while delivering the same seller and winner mails. + { + clearFixtureMail(); + uint32 const auctionId = 995123; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, bidderGuid, 100, 0, 20); + if (!auction) + { + printf("ahcustodyroute FAIL: full custody fixture creation\n"); + pass = false; + } + else + { + uint32 const itemGuid = auction->itemGuidLow; + std::string const itemKey = "item:995123"; + std::string const depKey = "dep:995123"; + std::string const bidKey = "bid:995123:1"; + seedRow(itemKey, CUSTODY_ITEM, ROLE_ITEM, + sellerGuid, 0, itemGuid, auctionId); + seedRow(depKey, CUSTODY_GOLD, ROLE_DEPOSIT, + sellerGuid, 20, 0, auctionId); + seedRow(bidKey, CUSTODY_GOLD, ROLE_BID, + bidderGuid, 100, 0, auctionId); + + CustodyDeferred def; + CharacterDatabase.BeginTransaction(); + auction->AuctionBidWinningCustody(NULL, def, true, true, bidKey); + bool const committed = CharacterDatabase.CommitTransactionChecked(); + if (committed) + { + def.run(); + } + if (!committed || auctionExists(auctionId) || + rowState(itemKey) != CST_TERMINAL_OK || + rowState(depKey) != CST_TERMINAL_BACK || + rowState(bidKey) != CST_TERMINAL_OK || + mailCount(sellerGuid, AUCTION_SUCCESSFUL) != 1 || + mailCount(bidderGuid, AUCTION_WON) != 1 || + itemMailCount(bidderGuid, itemGuid) != 1) + { + printf("ahcustodyroute FAIL: full custody winning regression\n"); + pass = false; + } + } + } + + for (size_t i = 0; i < itemGuids.size(); ++i) + { + Item* liveItem = sAuctionMgr.GetAItem(itemGuids[i]); + if (liveItem) + { + sAuctionMgr.RemoveAItem(itemGuids[i]); + delete liveItem; + } + CharacterDatabase.DirectPExecute( + "DELETE FROM `mail_items` WHERE `item_guid`=%u", itemGuids[i]); + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", itemGuids[i]); + } + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver` IN (9501,9502,9503)"); + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id` BETWEEN 995100 AND 995199"); + CharacterDatabase.DirectExecute( + "DELETE FROM `auction` WHERE `id` BETWEEN 995100 AND 995199"); + CharacterDatabase.DirectExecute( + "DELETE FROM `characters` WHERE `guid` IN (9501,9502,9503)"); + + if (pass) + { + printf("ahcustodyroute OK\n"); + return 0; + } + return 2; +} + /// Regression for a real SP-2 smoke failure: the worker committed a cancel and /// removed the auction, but mangosd missed the terminal result before restart. /// The pending map is then empty, so repair must replay from ah_worker_journal @@ -4177,6 +4881,11 @@ int RunMangosdTest(std::string const& name) return RunAhRepairRecoveryTest(); } + if (name == "ahcustodyroute") + { + return RunAhCustodyRouteTest(); + } + if (name == "ahmaterialize") { return RunAhMaterializeTest(); diff --git a/src/modules/AhWorker/tools/custody_crash_test.md b/src/modules/AhWorker/tools/custody_crash_test.md index 68f707c3e..7ddb68015 100644 --- a/src/modules/AhWorker/tools/custody_crash_test.md +++ b/src/modules/AhWorker/tools/custody_crash_test.md @@ -57,7 +57,7 @@ stdout, then calls `_exit(3)`. 4. Set `AH.Service.Custody = 1`. 5. Ensure the auction under test is custody-managed when the row requires it: it must have `custody_ledger` rows, because the live gate is - `AH.Service.Custody && CustodyLedger::HasRows(auction_id)`. + `AH.Service.Custody` plus the auction's exact seller/bid route state. 6. Record baseline counts before the seam: ```sql From 299d744634d8e66ede90ad0d5f742a6caed44d1f Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 13:04:00 +0100 Subject: [PATCH 07/18] fix(ah): ignore empty custody crash phases --- src/game/AuctionHouseBot/CustodyService.cpp | 8 +++++++- src/game/AuctionHouseBot/CustodyService.h | 4 ++++ src/mangosd/MangosdTest.cpp | 10 ++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/game/AuctionHouseBot/CustodyService.cpp b/src/game/AuctionHouseBot/CustodyService.cpp index bc6622905..311f15813 100644 --- a/src/game/AuctionHouseBot/CustodyService.cpp +++ b/src/game/AuctionHouseBot/CustodyService.cpp @@ -228,9 +228,15 @@ std::string CustodyService::CrashPhase() return sConfig.GetStringDefault("AH.Service.CustodyCrashAt", ""); } +bool CustodyService::ShouldCrashAtPhase(std::string const& configuredPhase, + std::string const& phase) +{ + return !phase.empty() && configuredPhase == phase; +} + void CustodyService::MaybeCrash(std::string const& phase) { - if (CrashPhase() == phase) + if (ShouldCrashAtPhase(CrashPhase(), phase)) { sLog.outError("custody crash-injection: _exit(3) at phase '%s' (TEST ONLY)", phase.c_str()); fflush(NULL); // flush ALL stdio streams (incl. the log FILE*) -- _exit skips cleanup diff --git a/src/game/AuctionHouseBot/CustodyService.h b/src/game/AuctionHouseBot/CustodyService.h index 034ec8d21..a30dc04a9 100644 --- a/src/game/AuctionHouseBot/CustodyService.h +++ b/src/game/AuctionHouseBot/CustodyService.h @@ -228,6 +228,10 @@ namespace CustodyService /// (empty = off, "pre-commit", "pre-deferred"). Never set on a live realm. std::string CrashPhase(); + /// Pure crash-injection match predicate, exposed for the self-test harness. + bool ShouldCrashAtPhase(std::string const& configuredPhase, + std::string const& phase); + /// TEST ONLY. If AH.Service.CustodyCrashAt == @p phase, flush + _exit(3) to /// simulate process death at that custody-seam transition. No-op when the /// config is empty (the live default), so it is inert on a real realm. diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 0e0d2ac80..45e132e2f 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -786,6 +786,16 @@ static int RunCustodyTest() { bool pass = true; + if (CustodyService::ShouldCrashAtPhase("", "") || + CustodyService::ShouldCrashAtPhase("pre-commit", "") || + CustodyService::ShouldCrashAtPhase("", "pre-commit") || + CustodyService::ShouldCrashAtPhase("pre-commit", "pre-deferred") || + !CustodyService::ShouldCrashAtPhase("pre-commit", "pre-commit")) + { + printf("custody FAIL: crash phase predicate mismatch\n"); + pass = false; + } + CharacterDatabase.AllowAsyncTransactions(); // Clean slate from any prior aborted run. From f72b890117536decb8e523fe63ebb8e7af24361f Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 13:07:20 +0100 Subject: [PATCH 08/18] fix(ah): fingerprint complete bid custody shape --- .../AuctionHouseBot/CustodyReconciler.cpp | 40 ++++++++++++++++++- src/mangosd/MangosdTest.cpp | 17 ++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/game/AuctionHouseBot/CustodyReconciler.cpp b/src/game/AuctionHouseBot/CustodyReconciler.cpp index 652d7764d..b812eba3a 100644 --- a/src/game/AuctionHouseBot/CustodyReconciler.cpp +++ b/src/game/AuctionHouseBot/CustodyReconciler.cpp @@ -94,11 +94,43 @@ namespace { return lhs->idemKey < rhs->idemKey; } + if (lhs->kind != rhs->kind) + { + return lhs->kind < rhs->kind; + } + if (lhs->role != rhs->role) + { + return lhs->role < rhs->role; + } + if (lhs->state != rhs->state) + { + return lhs->state < rhs->state; + } if (lhs->ownerGuid != rhs->ownerGuid) { return lhs->ownerGuid < rhs->ownerGuid; } - return lhs->amount < rhs->amount; + if (lhs->beneficiaryGuid != rhs->beneficiaryGuid) + { + return lhs->beneficiaryGuid < rhs->beneficiaryGuid; + } + if (lhs->amount != rhs->amount) + { + return lhs->amount < rhs->amount; + } + if (lhs->itemGuid != rhs->itemGuid) + { + return lhs->itemGuid < rhs->itemGuid; + } + if (lhs->auctionId != rhs->auctionId) + { + return lhs->auctionId < rhs->auctionId; + } + if (lhs->createdTime != rhs->createdTime) + { + return lhs->createdTime < rhs->createdTime; + } + return lhs->resolvedTime < rhs->resolvedTime; } std::string BidFingerprint(CustodyAuctionFacts const& auction, @@ -117,7 +149,11 @@ namespace { CustodyRow const& row = *bidRows[i]; out << ';' << row.id << ',' << row.idemKey << ',' - << row.ownerGuid << ',' << row.amount; + << uint32(row.kind) << ',' << uint32(row.role) << ',' + << uint32(row.state) << ',' << row.ownerGuid << ',' + << row.beneficiaryGuid << ',' << row.amount << ',' + << row.itemGuid << ',' << row.auctionId << ',' + << row.createdTime << ',' << row.resolvedTime; } return out.str(); } diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 45e132e2f..ea0cdba7a 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -418,6 +418,23 @@ static bool RunPureCustodyReconcilerTests() pass = false; } + std::vector wrongShapeRows; + wrongShapeRows.push_back(marker(980017, 880017)); + wrongShapeRows.push_back(bidRow(2, 980017, 2217, 217)); + wrongShapeRows[1].itemGuid = 1; + std::vector wrongShapeGroups(1, + TestCustodyGroup(980017, 880017, botOwner, 2217, 217, 0, + wrongShapeRows)); + CustodyReconciler rowChanged; + rowChanged.Scan(wrongShapeGroups, 1000, CUSTODY_SCAN_RUNTIME, report); + wrongShapeGroups[0].rows[1].itemGuid = 2; + rowChanged.Scan(wrongShapeGroups, 1060, CUSTODY_SCAN_RUNTIME, report); + if (report.pendingBidCount != 1 || report.confirmedDriftCount != 0) + { + printf("custody FAIL: changed bid-row shape inherited mismatch age\n"); + pass = false; + } + CustodyReconciler fresh; fresh.Scan(groups, 1000, CUSTODY_SCAN_RUNTIME, report); groups[0].rows.push_back(bidRow(2, 980016, 9999, 217, 1100)); From 33018b848d0b3de41bc765b867083402eba89faa Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 13:26:50 +0100 Subject: [PATCH 09/18] fix(ah): drain custody rows after runtime disable --- src/game/Object/AuctionHouseMgr.cpp | 10 ++++---- .../WorldHandlers/AuctionHouseHandler.cpp | 16 +++++-------- src/mangosd/MangosdTest.cpp | 23 ++++++++++--------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/game/Object/AuctionHouseMgr.cpp b/src/game/Object/AuctionHouseMgr.cpp index 9e298b3fb..9293bdff5 100644 --- a/src/game/Object/AuctionHouseMgr.cpp +++ b/src/game/Object/AuctionHouseMgr.cpp @@ -1061,11 +1061,10 @@ void AuctionHouseObject::Update() AuctionEntryMap::iterator old = itr++; if (curTime > old->second->expireTime) { - CustodyRouteState route = {}; - if (sWorld.IsAhCustodyEnabled()) - { - route = CustodyLedger::GetRouteState(old->second->Id); - } + // Runtime disable stops config-gated custody entry and maintenance, + // not settlement of value already represented by durable rows. + CustodyRouteState const route = + CustodyLedger::GetRouteState(old->second->Id); ///- perform the transaction if there was bidder if (old->second->bid) @@ -1752,6 +1751,7 @@ void AuctionEntry::PrepareCancelCustody(Player* seller, CustodyDeferred& def, } Item* item = sAuctionMgr.GetAItem(itemGuidLow); + MANGOS_ASSERT(item); uint32 const savedItemGuidLow = itemGuidLow; def.effects.push_back([savedItemGuidLow]() { diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index 0f48fcff2..ffc2e052a 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -1105,11 +1105,9 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recv_data) newOutbid = 1; } - CustodyRouteState route = {}; - if (sWorld.IsAhCustodyEnabled()) - { - route = CustodyLedger::GetRouteState(auction->Id); - } + // A reload may disable config-gated custody entry and maintenance, but + // durable rows already in flight remain authoritative until terminal. + CustodyRouteState const route = CustodyLedger::GetRouteState(auction->Id); // A player-seller row or a player-bid row selects the combined transaction. // Marker-only bot listings remain entirely on the legacy path. @@ -1294,11 +1292,9 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) return; } - CustodyRouteState route = {}; - if (sWorld.IsAhCustodyEnabled()) - { - route = CustodyLedger::GetRouteState(auction->Id); - } + // A reload may disable config-gated custody entry and maintenance, but + // durable rows already in flight remain authoritative until terminal. + CustodyRouteState const route = CustodyLedger::GetRouteState(auction->Id); if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) { diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index ea0cdba7a..433a9d155 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -4216,7 +4216,8 @@ static int RunAhCustodyRouteTest() } // Existing full player seller+bid custody still terminalizes all three - // value rows while delivering the same seller and winner mails. + // value rows after a runtime custody disable. The flag stops maintenance + // and config-gated entry; it must not abandon durable rows already in flight. { clearFixtureMail(); uint32 const auctionId = 995123; @@ -4240,15 +4241,15 @@ static int RunAhCustodyRouteTest() seedRow(bidKey, CUSTODY_GOLD, ROLE_BID, bidderGuid, 100, 0, auctionId); - CustodyDeferred def; - CharacterDatabase.BeginTransaction(); - auction->AuctionBidWinningCustody(NULL, def, true, true, bidKey); - bool const committed = CharacterDatabase.CommitTransactionChecked(); - if (committed) - { - def.run(); - } - if (!committed || auctionExists(auctionId) || + auction->expireTime = static_cast(-1); + AuctionHouseObject* houseMap = sAuctionMgr.GetAuctionsMap(&house); + houseMap->AddAuction(auction); + bool const custodyWasEnabled = sWorld.IsAhCustodyEnabled(); + sWorld.setConfig(CONFIG_BOOL_AH_CUSTODY, false); + houseMap->Update(); + sWorld.setConfig(CONFIG_BOOL_AH_CUSTODY, custodyWasEnabled); + + if (auctionExists(auctionId) || rowState(itemKey) != CST_TERMINAL_OK || rowState(depKey) != CST_TERMINAL_BACK || rowState(bidKey) != CST_TERMINAL_OK || @@ -4256,7 +4257,7 @@ static int RunAhCustodyRouteTest() mailCount(bidderGuid, AUCTION_WON) != 1 || itemMailCount(bidderGuid, itemGuid) != 1) { - printf("ahcustodyroute FAIL: full custody winning regression\n"); + printf("ahcustodyroute FAIL: runtime-disable winning regression\n"); pass = false; } } From b34da9d5a79f4e4ab95e58af87d4f790ddfa5645 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 4 Aug 2026 15:58:24 +0100 Subject: [PATCH 10/18] fix(ah): log sweep-owned custody rows as diagnostics --- src/game/AuctionHouseBot/CustodyService.cpp | 24 +++++++++++++++---- src/game/AuctionHouseBot/CustodyService.h | 4 ++++ src/mangosd/MangosdTest.cpp | 26 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/game/AuctionHouseBot/CustodyService.cpp b/src/game/AuctionHouseBot/CustodyService.cpp index 311f15813..7a0ef00dd 100644 --- a/src/game/AuctionHouseBot/CustodyService.cpp +++ b/src/game/AuctionHouseBot/CustodyService.cpp @@ -271,6 +271,12 @@ void CustodyService::ReconcileScan(uint64 now, CustodyScanContext context, s_reconciler.Scan(snapshot, now, context, report); } +bool CustodyService::ReconcileFindingIsError(CustodyFinding const& finding) +{ + return finding.state == CUSTODY_FINDING_CONFIRMED && + finding.repairOwnership != CUSTODY_REPAIR_BOT_SWEEP; +} + void CustodyService::LogReconcileReport( char const* phase, CustodyReconcileReport const& report) { @@ -289,10 +295,20 @@ void CustodyService::LogReconcileReport( continue; } - sLog.outError("custody reconcile %s detail: auction=%u key=%s " - "reason=%s", phase, finding.row.auctionId, - finding.row.idemKey.c_str(), - CustodyFindingReasonName(finding.reason)); + if (ReconcileFindingIsError(finding)) + { + sLog.outError("custody reconcile %s detail: auction=%u key=%s " + "reason=%s", phase, finding.row.auctionId, + finding.row.idemKey.c_str(), + CustodyFindingReasonName(finding.reason)); + } + else + { + sLog.outString("custody reconcile %s detail: auction=%u key=%s " + "reason=%s", phase, finding.row.auctionId, + finding.row.idemKey.c_str(), + CustodyFindingReasonName(finding.reason)); + } } if (budget.Suppressed()) diff --git a/src/game/AuctionHouseBot/CustodyService.h b/src/game/AuctionHouseBot/CustodyService.h index a30dc04a9..3ac64d34b 100644 --- a/src/game/AuctionHouseBot/CustodyService.h +++ b/src/game/AuctionHouseBot/CustodyService.h @@ -254,6 +254,10 @@ namespace CustodyService void LogReconcileReport(char const* phase, CustodyReconcileReport const& report); + /// True when a reconciliation finding represents confirmed operator-actionable + /// drift and should therefore be emitted at error severity. + bool ReconcileFindingIsError(CustodyFinding const& finding); + CustodyMaintenancePlan GetMaintenancePlan(bool custodyEnabled, bool writeAuthorityEnabled); } diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 433a9d155..f8749498d 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -532,6 +532,32 @@ static bool RunPureCustodyReconcilerTests() } } + // Expected bot-sweep ownership and transient bid observations are + // diagnostics, not errors. Confirmed player/manual drift remains an error. + { + CustodyFinding confirmed = {}; + confirmed.repairOwnership = CUSTODY_REPAIR_GENERIC; + confirmed.state = CUSTODY_FINDING_CONFIRMED; + + CustodyFinding manual = confirmed; + manual.repairOwnership = CUSTODY_REPAIR_MANUAL_ONLY; + + CustodyFinding pending = confirmed; + pending.state = CUSTODY_FINDING_PENDING; + + CustodyFinding sweepOwned = confirmed; + sweepOwned.repairOwnership = CUSTODY_REPAIR_BOT_SWEEP; + + if (!CustodyService::ReconcileFindingIsError(confirmed) || + !CustodyService::ReconcileFindingIsError(manual) || + CustodyService::ReconcileFindingIsError(pending) || + CustodyService::ReconcileFindingIsError(sweepOwned)) + { + printf("custody FAIL: reconcile finding severity policy mismatch\n"); + pass = false; + } + } + // Each automatic/manual operation owns one independent detail budget. { CustodyDetailBudget budget(100); From c4f492e18abf115767db0e639a88af90b0de5edf Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 00:39:07 +0100 Subject: [PATCH 11/18] fix(ah): batch orphan materialization recovery --- .../AuctionHouseBot/AuctionIntentExecutor.cpp | 141 +++++++++++++----- .../AuctionHouseBot/AuctionIntentExecutor.h | 19 ++- src/game/WorldHandlers/World.cpp | 43 ++++-- src/mangosd/MangosdTest.cpp | 57 ++++++- 4 files changed, 208 insertions(+), 52 deletions(-) diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp index 9d9258865..39e051fad 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include "AuctionIntentExecutor.h" #include "AuctionIntents.h" @@ -722,8 +724,17 @@ void AuctionIntentExecutor::TestMaterializeSell(SellIntent const& s, MaterializeSell(s, resultOut, now); } -void AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec) +OrphanMaterializationSweepReport +AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, + uint32 maxRows) { + OrphanMaterializationSweepReport report = {}; + report.committed = true; + if (maxRows == 0u) + { + return report; + } + // Grace window: only rows older than T are candidates, so a materialize // whose book-commit is still in flight on the worker is never reaped. static const uint32 ORPHAN_GRACE_SEC = 300u; @@ -734,53 +745,113 @@ void AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec) // Candidates: durable botlist rows, past the grace window, whose auction id // is absent from the shared `auction` table (worker never wrote / already // removed the book row). + uint64 const queryLimit = uint64(maxRows) + 1u; QueryResult* q = CharacterDatabase.PQuery( - "SELECT `idem_key`, `item_guid`, `auction_id`, `owner_guid` " + "SELECT `id`, `item_guid`, `owner_guid` " "FROM `custody_ledger` " "WHERE `idem_key` LIKE 'botlist:%%' AND `created_time` < " UI64FMTD " " - "AND `auction_id` NOT IN (SELECT `id` FROM `auction`)", - cutoff); + "AND `auction_id` NOT IN (SELECT `id` FROM `auction`) " + "ORDER BY `id` LIMIT " UI64FMTD, + cutoff, queryLimit); if (q == NULL) { - return; + return report; } + struct Candidate + { + uint32 ledgerId; + uint32 itemGuid; + uint32 ownerGuid; + }; + std::vector candidates; + candidates.reserve(maxRows); do { + if (candidates.size() == maxRows) + { + report.morePending = true; + break; + } + Field* f = q->Fetch(); - std::string idemKey = f[0].GetCppString(); - uint32 const itemGuid = f[1].GetUInt32(); - uint32 const ownerGuid = f[3].GetUInt32(); - CharacterDatabase.escape_string(idemKey); - - // Delete the minted item ONLY while it is still the bot's AND is not - // attached to any mail. This is the safety that distinguishes a - // genuinely stranded mint (crashed before book-commit: still bot-owned, - // never mailed) from a listing that DID reach the book and later - // sold/returned -- whose item is now the buyer's (owner changed) or is - // sitting in the bot's return mail (mail_items ref). The stale botlist - // row itself is always removed, bounding custody_ledger growth for - // resolved listings too. - CharacterDatabase.BeginTransaction(); - CharacterDatabase.PExecute( - "DELETE FROM `item_instance` WHERE `guid` = %u " - "AND `owner_guid` = %u " - "AND `guid` NOT IN (SELECT `item_guid` FROM `mail_items`)", - itemGuid, ownerGuid); - CharacterDatabase.PExecute( - "DELETE FROM `custody_ledger` WHERE `idem_key` = '%s'", - idemKey.c_str()); - CharacterDatabase.CommitTransactionChecked(); - - // Drop the in-memory escrow (harmless no-op after a restart, where the - // orphaned item was never re-loaded into mAitems). - sAuctionMgr.RemoveAItem(itemGuid); - sLog.outString("[AHExecutor] swept orphan materialization %s (item %u)", - f[0].GetCppString().c_str(), itemGuid); + Candidate candidate; + candidate.ledgerId = f[0].GetUInt32(); + candidate.itemGuid = f[1].GetUInt32(); + candidate.ownerGuid = f[2].GetUInt32(); + candidates.push_back(candidate); } while (q->NextRow()); - delete q; + + report.selected = uint32(candidates.size()); + if (candidates.empty()) + { + return report; + } + + // Keep both SQL statement count and row count bounded. One item DELETE + // preserves the old owner/mail guards for every selected marker; one ledger + // DELETE retires the markers. The mail subquery is evaluated once per batch, + // rather than once per row. + std::ostringstream deleteItems; + std::ostringstream deleteMarkers; + deleteItems << "DELETE FROM `item_instance` WHERE ("; + deleteMarkers << "DELETE FROM `custody_ledger` WHERE `id` IN ("; + for (size_t i = 0; i < candidates.size(); ++i) + { + if (i != 0u) + { + deleteItems << " OR "; + deleteMarkers << ','; + } + deleteItems << "(`guid` = " << candidates[i].itemGuid + << " AND `owner_guid` = " << candidates[i].ownerGuid << ')'; + deleteMarkers << candidates[i].ledgerId; + } + deleteItems << ") AND `guid` NOT IN " + "(SELECT `item_guid` FROM `mail_items`)"; + deleteMarkers << ')'; + + if (!CharacterDatabase.BeginTransaction()) + { + report.committed = false; + sLog.outError("[AHExecutor] orphan materialization sweep could not" + " begin transaction (selected=%u)", report.selected); + return report; + } + + bool const queued = CharacterDatabase.Execute(deleteItems.str().c_str()) && + CharacterDatabase.Execute(deleteMarkers.str().c_str()); + if (!queued) + { + CharacterDatabase.RollbackTransaction(); + report.committed = false; + sLog.outError("[AHExecutor] orphan materialization sweep could not queue" + " batch (selected=%u)", report.selected); + return report; + } + + if (!CharacterDatabase.CommitTransactionChecked()) + { + report.committed = false; + sLog.outError("[AHExecutor] orphan materialization sweep transaction" + " rolled back (selected=%u)", report.selected); + return report; + } + + // Drop in-memory escrow only after both durable deletes commit. This is a + // harmless no-op after restart, where the orphan was never reloaded. + for (std::vector::const_iterator it = candidates.begin(); + it != candidates.end(); ++it) + { + sAuctionMgr.RemoveAItem(it->itemGuid); + } + report.swept = report.selected; + sLog.outString("[AHExecutor] orphan materialization sweep:" + " swept=%u more-pending=%u", + report.swept, report.morePending ? 1u : 0u); + return report; } void AuctionIntentExecutor::ApplyBid(const IpcMessage& in, diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.h b/src/game/AuctionHouseBot/AuctionIntentExecutor.h index 5317e745f..d2b8b6033 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.h +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.h @@ -36,6 +36,14 @@ /// keeps the ipc header out of every TU that includes this executor. struct SellIntent; +struct OrphanMaterializationSweepReport +{ + uint32 selected; + uint32 swept; + bool morePending; + bool committed; +}; + /** * @file AuctionIntentExecutor.h * @brief mangosd-side (authority) executor for AH subprocess intents. @@ -128,9 +136,16 @@ class AuctionIntentExecutor * custody_ledger growth for resolved listings too). Runs on the AHBot * update tick under WriteAuthority. * - * @param nowSec Current game-time second (unix epoch; == time(NULL)). + * Work is capped at @p maxRows and ordered deterministically so an + * outage backlog can drain across multiple world ticks. All durable + * changes commit together; live escrow changes happen only afterward. + * + * @param nowSec Current game-time second (unix epoch; == time(NULL)). + * @param maxRows Maximum markers to process in this invocation. + * @return Batch progress and durable commit status. */ - void SweepOrphanMaterializations(uint32 nowSec); + OrphanMaterializationSweepReport SweepOrphanMaterializations( + uint32 nowSec, uint32 maxRows); /** * @brief [SP-2] Test-only seam for @c mangosd -t ahmaterialize. diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index 7398ba421..672cbf835 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -1271,21 +1271,23 @@ void World::Update(uint32 diff) // WUPDATE_AHBOT tick in either mode is cheap and idempotent. sAuctionBot.PurgeMailedItemsTick(); - // Custody drift audit + terminal-row TTL prune. This is intentionally - // a fixed retention constant, not a config key, so no mangosd.conf - // version bump is required for Task 13. + // Custody drift audit + terminal-row TTL prune. Orphan materialization + // recovery has a separate adaptive timer so outage backlogs drain in + // bounded batches without repeating the full reconciliation scan. static uint64 s_nextCustodyReconcileTime = 0; + static uint64 s_nextOrphanSweepTime = 0; uint64 const now = static_cast(GetGameTime()); uint64 const custodyTerminalRetention = 30 * DAY; + uint32 const orphanSweepBatchSize = 100u; + CustodyMaintenancePlan const plan = + CustodyService::GetMaintenancePlan(IsAhCustodyEnabled(), + IsAhWriteAuthority()); if (!s_nextCustodyReconcileTime) { s_nextCustodyReconcileTime = now + HOUR; } else if (now >= s_nextCustodyReconcileTime) { - CustodyMaintenancePlan const plan = - CustodyService::GetMaintenancePlan(IsAhCustodyEnabled(), - IsAhWriteAuthority()); if (plan.reconcile) { CustodyReconcileReport report; @@ -1299,17 +1301,30 @@ void World::Update(uint32 diff) CustodyLedger::DeleteTerminalOlderThan(now - custodyTerminalRetention); } - // SP-2 Task 13: reap bot-listing materializations whose auction - // never reached the shared `auction` table (a worker that died - // between the materialize reply and the book-commit). Only under - // WriteAuthority -- the legacy in-process bot owns its own book and - // never mints via this path. + s_nextCustodyReconcileTime = now + HOUR; + } + + if (!s_nextOrphanSweepTime) + { + s_nextOrphanSweepTime = now + MINUTE; + } + else if (now >= s_nextOrphanSweepTime) + { + // Only WriteAuthority materializes bot listings through this path. + // A full batch (or failed commit) retries in one minute; a drained + // queue returns to a cheap hourly check. if (plan.sweepBotMaterializations) { - sAuctionIntentExecutor.SweepOrphanMaterializations(uint32(now)); + OrphanMaterializationSweepReport const sweep = + sAuctionIntentExecutor.SweepOrphanMaterializations( + uint32(now), orphanSweepBatchSize); + bool const retrySoon = sweep.morePending || !sweep.committed; + s_nextOrphanSweepTime = now + (retrySoon ? MINUTE : HOUR); + } + else + { + s_nextOrphanSweepTime = now + HOUR; } - - s_nextCustodyReconcileTime = now + HOUR; } m_timers[WUPDATE_AHBOT].Reset(); diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index f8749498d..8a20e56e8 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -4617,6 +4617,9 @@ static int RunAhMaterializeTest() CharacterDatabase.DirectPExecute( "DELETE FROM `custody_ledger` WHERE `idem_key` IN " "('%s','botlist:test:orphan','botlist:test:sold')", key.c_str()); + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` " + "WHERE `idem_key` LIKE 'botlist:test:batch:%'"); CharacterDatabase.DirectPExecute( "DELETE FROM `item_instance` WHERE `owner_guid` IN (%u,%u)", botGuid, buyerGuid); @@ -4812,7 +4815,8 @@ static int RunAhMaterializeTest() return 2; } - sAuctionIntentExecutor.SweepOrphanMaterializations(uint32(time(NULL))); + sAuctionIntentExecutor.SweepOrphanMaterializations( + uint32(time(NULL)), 100u); std::unique_ptr qo(CharacterDatabase.PQuery( "SELECT 1 FROM `item_instance` WHERE `guid`=%u", orphanItem)); @@ -4843,10 +4847,61 @@ static int RunAhMaterializeTest() } } + // ---- Part 4: an outage backlog drains across bounded invocations ---- + { + CharacterDatabase.BeginTransaction(); + for (uint32 i = 1u; i <= 101u; ++i) + { + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`," + "`beneficiary_guid`,`amount`,`item_guid`,`auction_id`," + "`created_time`,`resolved_time`) " + "VALUES ('botlist:test:batch:%u',1,4,0,%u,0,0,%u,%u,100,0)", + i, botGuid, 99911310u + i, 99900010u + i); + } + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahmaterialize FAIL: bounded sweep seed commit\n"); + return 2; + } + + auto countBatchRows = []() -> uint64 + { + std::unique_ptr rows(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `custody_ledger` " + "WHERE `idem_key` LIKE 'botlist:test:batch:%%'")); + return rows ? rows->Fetch()[0].GetUInt64() : 0u; + }; + + OrphanMaterializationSweepReport const first = + sAuctionIntentExecutor.SweepOrphanMaterializations( + uint32(time(NULL)), 100u); + if (!first.committed || first.selected != 100u || first.swept != 100u || + !first.morePending || countBatchRows() != 1u) + { + printf("ahmaterialize FAIL: first bounded sweep batch\n"); + pass = false; + } + + OrphanMaterializationSweepReport const second = + sAuctionIntentExecutor.SweepOrphanMaterializations( + uint32(time(NULL)), 100u); + if (!second.committed || second.selected != 1u || second.swept != 1u || + second.morePending || countBatchRows() != 0u) + { + printf("ahmaterialize FAIL: second bounded sweep batch\n"); + pass = false; + } + } + // Clean up (Part-1 minted item survives the sweep; drop it + fixtures). CharacterDatabase.DirectPExecute( "DELETE FROM `custody_ledger` WHERE `idem_key` IN " "('%s','botlist:test:orphan','botlist:test:sold')", key.c_str()); + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` " + "WHERE `idem_key` LIKE 'botlist:test:batch:%'"); CharacterDatabase.DirectPExecute( "DELETE FROM `item_instance` WHERE `owner_guid` IN (%u,%u)", botGuid, buyerGuid); From 8cb00eb3fb10ce38b4fb86228070668afd66b33b Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 16 Jul 2026 00:56:58 +0100 Subject: [PATCH 12/18] fix(ahworker): preserve custody through recovery --- .../AuctionHouseBot/AuctionIntentExecutor.cpp | 5 +- src/game/AuctionHouseBot/CustodyService.cpp | 15 +- src/game/AuctionHouseBot/CustodyService.h | 7 +- src/game/AuctionHouseBot/MutationPending.h | 4 + .../WorldHandlers/AuctionHouseHandler.cpp | 538 ++++++--- src/game/WorldHandlers/World.cpp | 4 +- src/mangosd/MangosdTest.cpp | 1032 ++++++++++++++++- src/mangosd/mangosd.conf.dist.in | 7 +- 8 files changed, 1396 insertions(+), 216 deletions(-) diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp index 39e051fad..c7eafc002 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp @@ -31,6 +31,7 @@ #include "AuctionHouseMgr.h" #include "AuctionHouseBot.h" #include "CustodyLedger.h" +#include "CustodyService.h" #include "ObjectMgr.h" #include "ItemPrototype.h" #include "Item.h" @@ -832,7 +833,7 @@ AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, return report; } - if (!CharacterDatabase.CommitTransactionChecked()) + if (!CustodyService::CommitCheckedOrForcedFail("orphan-sweep")) { report.committed = false; sLog.outError("[AHExecutor] orphan materialization sweep transaction" @@ -845,7 +846,9 @@ AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, for (std::vector::const_iterator it = candidates.begin(); it != candidates.end(); ++it) { + Item* const orphan = sAuctionMgr.GetAItem(it->itemGuid); sAuctionMgr.RemoveAItem(it->itemGuid); + delete orphan; } report.swept = report.selected; sLog.outString("[AHExecutor] orphan materialization sweep:" diff --git a/src/game/AuctionHouseBot/CustodyService.cpp b/src/game/AuctionHouseBot/CustodyService.cpp index 7a0ef00dd..1124157b7 100644 --- a/src/game/AuctionHouseBot/CustodyService.cpp +++ b/src/game/AuctionHouseBot/CustodyService.cpp @@ -244,19 +244,20 @@ void CustodyService::MaybeCrash(std::string const& phase) } } -bool CustodyService::CommitCheckedOrForcedFail(std::string const& phase) +bool CustodyService::CommitCheckedOrForcedFail( + std::string const& phase) { - // One-shot per process: the FIRST finalize whose phase is armed rolls back - // and reports failure (-> the caller runs its X6 in-memory undo and queues - // the redrive); the redrive's re-attempt takes the real checked commit and - // succeeds. This proves the worker book is never rolled back on a failed - // mangosd-side finalize. Inert (a plain checked commit) on a live realm. + // One-shot per process: the first armed custody transaction rolls back. + // Its caller must retain the disposition and undo any in-memory effects so + // the next reconciliation attempt can take the real checked commit. static bool s_forcedFailFired = false; if (!s_forcedFailFired && !phase.empty() && sConfig.GetStringDefault("AH.Service.CustodyFailCommitAt", "") == phase) { s_forcedFailFired = true; - sLog.outError("custody forced commit-fail: rollback at phase '%s' (TEST ONLY, one-shot)", phase.c_str()); + sLog.outError( + "custody forced commit-fail: rollback at phase '%s' " + "(TEST ONLY, one-shot)", phase.c_str()); CharacterDatabase.RollbackTransaction(); return false; } diff --git a/src/game/AuctionHouseBot/CustodyService.h b/src/game/AuctionHouseBot/CustodyService.h index 3ac64d34b..39ffd5622 100644 --- a/src/game/AuctionHouseBot/CustodyService.h +++ b/src/game/AuctionHouseBot/CustodyService.h @@ -237,12 +237,11 @@ namespace CustodyService /// config is empty (the live default), so it is inert on a real realm. void MaybeCrash(std::string const& phase); - /// TEST ONLY. Checked-commit wrapper for a finalize transaction. If + /// TEST ONLY. Checked-commit wrapper for an AH custody transaction. If /// AH.Service.CustodyFailCommitAt == @p phase, rolls the caller's OPEN /// CharacterDatabase transaction back and returns false ONCE (one-shot per - /// process) to simulate a failed finalize checked-commit, exercising the - /// redrive-without-rollback path (spec 4.1 step 4). Otherwise -- and always - /// once the one-shot has fired -- it just delegates to + /// process) to exercise the caller's retry/retention path. Otherwise -- and + /// always once the one-shot has fired -- it just delegates to /// CommitTransactionChecked(). Inert when the config is empty (live default). bool CommitCheckedOrForcedFail(std::string const& phase); diff --git a/src/game/AuctionHouseBot/MutationPending.h b/src/game/AuctionHouseBot/MutationPending.h index 203077e8a..3ea30f748 100644 --- a/src/game/AuctionHouseBot/MutationPending.h +++ b/src/game/AuctionHouseBot/MutationPending.h @@ -192,6 +192,10 @@ uint8 AhHandleResolveApply(ResolveApply const& ra); /// Reconcile-on-reconnect walk (spec 8). Implemented by Task 12. void AhReconcileOnReconnect(); +/// Retry reconnect dispositions retained after a transient local DB/read +/// failure. Called once per second while the AH service remains active. +void AhProcessReconnectRetryQueue(uint32 nowSec); + /// Forward-only re-attempt of finalizes whose checked commit failed (spec 4.1 /// step 4, "failed finalize") + the in-doubt tombstone sweep. Called once per /// second from World::Update while the AH service is active. diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index ffc2e052a..7e4af7e78 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -2146,6 +2146,39 @@ static std::string AhMailSubject(uint32 itemTemplate, int32 itemRand, uint32 res return s.str(); } +// Worker-authority terminal paths still use the live escrow cache until SP-6B's +// durable item-take lands. Missing or mismatched cache state must therefore +// hold custody for retry; it can never authorize a ledger-only item transition. +// The output row is a value snapshot and the output item remains cache-owned. +static bool AhGetCachedReservedItem(std::string const& key, uint32 auctionId, + uint32 ownerGuid, uint32 expectedItemGuid, + CustodyRow& row, Item*& item) +{ + item = NULL; + if (key.empty() || !CustodyLedger::Get(key, row) || + row.kind != CUSTODY_ITEM || row.role != ROLE_ITEM || + row.state != CST_RESERVED || row.ownerGuid != ownerGuid || + row.beneficiaryGuid != 0u || row.amount != 0u || + row.auctionId != auctionId || + (expectedItemGuid != 0u && row.itemGuid != expectedItemGuid)) + { + sLog.outError("[AHMut] item custody mismatch for key %s (auction %u); " + "holding for retry", + key.c_str(), auctionId); + return false; + } + + item = sAuctionMgr.GetAItem(row.itemGuid); + if (!item) + { + sLog.outError("[AHMut] auction %u escrow item %u missing from cache; " + "holding custody for retry", + auctionId, row.itemGuid); + return false; + } + return true; +} + // [SP-2] every live (CST_RESERVED CUSTODY_GOLD ROLE_BID) row for an auction. // LoadNonTerminal + filter: no new CustodyLedger read API; the non-terminal // set is TTL-bounded. @@ -2296,8 +2329,8 @@ static void AhSellerPayoutFromFacts(MutationFacts const& f, CustodyDeferred& def // [SP-2] replay of SendAuctionWonMailInTransaction from wire facts, minus the // GM-log block (server-side-only trace). A bot winner (curBidderGuid==0) // resolves to "no receiver" and follows the legacy destroy branch (spec -// section 3). Escrow-cache miss -> loud log, no item mail (the caller still -// terminalizes "item:"). +// section 3). Callers preflight the live escrow item before opening their +// transaction; the defensive miss below performs no value transition. static void AhItemToWinnerFromFacts(MutationFacts const& f, CustodyDeferred& def) { Item* pItem = sAuctionMgr.GetAItem(f.itemGuid); @@ -2465,16 +2498,16 @@ static void AhRefundCancelledBidderFromFacts(MutationFacts const& f, std::string // SendAuctionExpiredMailInTransaction / the S5 return: expired owner-notify // (EXPIRED response only), RemoveAItem deferred FIRST, DeliverItem flips // "item:" -> TERMINAL_OK and co-commits the mail; destroy branch when the -// account is gone; ledger-only flip when the escrow cache lost the Item*. +// account is gone. Callers preflight the cache before opening the transaction. static void AhReturnItemToSellerFromFacts(MutationFacts const& f, uint32 mailResponse, CustodyDeferred& def) { std::string const itemKey = "item:" + std::to_string(f.auctionId); Item* pItem = sAuctionMgr.GetAItem(f.itemGuid); if (!pItem) { - sLog.outError("[AHMut] auction %u return-item %u missing from escrow cache; ledger-only flip", + sLog.outError("[AHMut] auction %u return-item %u missing from escrow " + "cache; holding custody", f.auctionId, f.itemGuid); - CustodyService::CommitGoldLedgerOnly(itemKey); return; } @@ -2660,6 +2693,28 @@ static bool AhFinalizeBidOk(PlayerMutationResult const& res, PendingMutation con } uint32 const remainder = pm.reservedAmount - needed; + if (isBuyoutWin) + { + std::string const depKey = "dep:" + std::to_string(f.auctionId); + CustodyRow depRow; + CustodyRow itemRow; + Item* item = NULL; + if (!CustodyLedger::Get(depKey, depRow) || + depRow.kind != CUSTODY_GOLD || + depRow.role != ROLE_DEPOSIT || depRow.state != CST_RESERVED || + depRow.ownerGuid != f.sellerGuid || depRow.amount != f.deposit || + depRow.auctionId != f.auctionId || + !AhGetCachedReservedItem("item:" + std::to_string(f.auctionId), + f.auctionId, f.sellerGuid, f.itemGuid, + itemRow, item)) + { + sLog.outError("[AHMut] buyout terminal custody unavailable for " + "auction %u; queued for retry", + f.auctionId); + return false; + } + } + CustodyRow liveRow; std::string priorKey; if (f.priorBidderGuid != 0) @@ -2761,6 +2816,24 @@ static bool AhFinalizeCancelOk(PlayerMutationResult const& res, PendingMutation { MutationFacts const& f = res.facts; + std::string const depKey = "dep:" + std::to_string(f.auctionId); + CustodyRow depRow; + CustodyRow itemRow; + Item* item = NULL; + if (!CustodyLedger::Get(depKey, depRow) || depRow.kind != CUSTODY_GOLD || + depRow.role != ROLE_DEPOSIT || depRow.state != CST_RESERVED || + depRow.ownerGuid != f.sellerGuid || depRow.amount != f.deposit || + depRow.auctionId != f.auctionId || + !AhGetCachedReservedItem("item:" + std::to_string(f.auctionId), + f.auctionId, f.sellerGuid, f.itemGuid, + itemRow, item)) + { + sLog.outError("[AHMut] cancel terminal custody unavailable for auction " + "%u; queued for retry", + f.auctionId); + return false; + } + uint32 const cut = f.curBid ? AhCutFor(f.houseId, f.curBid) : 0; if (cut) { @@ -2850,46 +2923,62 @@ static bool AhFinalizeRejected(PlayerMutationResult const& res, PendingMutation Player* online = sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)); uint32 onlineCredit = 0; + CustodyRow sellDepRow; + CustodyRow sellItemRow; + Item* sellItem = NULL; + if (res.op == uint8(IPC_PLAYER_SELL & 0xFFu)) + { + if (pm.depKey.empty() || !CustodyLedger::Get(pm.depKey, sellDepRow) || + sellDepRow.kind != CUSTODY_GOLD || + sellDepRow.role != ROLE_DEPOSIT || + sellDepRow.state != CST_RESERVED || + sellDepRow.ownerGuid != pm.playerGuidLow || + sellDepRow.auctionId != pm.auctionId || + !AhGetCachedReservedItem(pm.itemKey, pm.auctionId, pm.playerGuidLow, + 0u, sellItemRow, sellItem)) + { + sLog.outError("[AHMut] rejected sell %u custody unavailable; " + "holding for retry", + pm.auctionId); + return false; + } + } + CustodyDeferred def; CharacterDatabase.BeginTransaction(); if (res.op == uint8(IPC_PLAYER_SELL & 0xFFu)) { - CustodyService::ReleaseGoldToWallet(def, pm.playerGuidLow, online, pm.reservedAmount, pm.depKey); + CustodyService::ReleaseGoldToWallet(def, pm.playerGuidLow, online, + sellDepRow.amount, pm.depKey); if (online) { - onlineCredit += pm.reservedAmount; + onlineCredit += sellDepRow.amount; } - CustodyRow itemRow; - if (!pm.itemKey.empty() && CustodyLedger::Get(pm.itemKey, itemRow) && itemRow.state == CST_RESERVED) + CustodyLedger::SetState(pm.itemKey, CST_TERMINAL_BACK, + static_cast(time(NULL))); + uint32 const savedItemGuidLow = sellItemRow.itemGuid; + def.effects.push_back([savedItemGuidLow]() { - Item* pItem = sAuctionMgr.GetAItem(itemRow.itemGuid); - CustodyLedger::SetState(pm.itemKey, CST_TERMINAL_BACK, static_cast(time(NULL))); - if (pItem) - { - uint32 const savedItemGuidLow = itemRow.itemGuid; - def.effects.push_back([savedItemGuidLow]() - { - sAuctionMgr.RemoveAItem(savedItemGuidLow); - }); - MailDraft ret(AhMailSubject(pItem->GetEntry(), pItem->GetItemRandomPropertyId(), AUCTION_CANCELED), ""); - ret.AddItem(pItem); - ret.SendMailToInTransaction(MailReceiver(online, ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)), - MailSender(MAIL_AUCTION, uint32(f.houseId), MAIL_STATIONERY_AUCTION), - def, MAIL_CHECK_MASK_COPIED); - } - else - { - sLog.outError("[AHMut] rejected sell %u: escrow item %u missing; ledger-only return", - pm.auctionId, itemRow.itemGuid); - } - } + sAuctionMgr.RemoveAItem(savedItemGuidLow); + }); + MailDraft ret(AhMailSubject(sellItem->GetEntry(), + sellItem->GetItemRandomPropertyId(), + AUCTION_CANCELED), ""); + ret.AddItem(sellItem); + ret.SendMailToInTransaction( + MailReceiver(online, + ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)), + MailSender(MAIL_AUCTION, uint32(f.houseId), + MAIL_STATIONERY_AUCTION), + def, MAIL_CHECK_MASK_COPIED); } else if (!pm.reserveKey.empty() && pm.reservedAmount > 0) { // bid / buyout (and, defensively, a post-CONFIRM cancel reject). - CustodyService::ReleaseGoldToWallet(def, pm.playerGuidLow, online, pm.reservedAmount, pm.reserveKey); + CustodyService::ReleaseGoldToWallet( + def, pm.playerGuidLow, online, pm.reservedAmount, pm.reserveKey); if (online) { onlineCredit += pm.reservedAmount; @@ -3009,7 +3098,9 @@ void AhHandlePlayerMutationResult(PlayerMutationResult const& res) e.attempts = 1; e.nextRetrySec = uint32(time(NULL)) + 5; s_ahRedrive.push_back(e); - sLog.outError("[AHMut] finalize checked-commit FAILED for uuid " UI64FMTD "; queued for redrive", res.uuid); + sLog.outError("[AHMut] finalize deferred for uuid " UI64FMTD + "; queued for redrive", + res.uuid); } } @@ -3232,6 +3323,28 @@ uint8 AhHandleResolveApply(ResolveApply const& ra) } MutationFacts const& f = ra.facts; + bool const repairRefundOnly = + (ra.kind == uint8(RESOLVE_REPAIR_RETURN) && + f.curBidderGuid == 0u && f.priorBidderGuid != 0u); + bool const needsItem = + (ra.kind == uint8(RESOLVE_WON) || + ra.kind == uint8(RESOLVE_EXPIRED_NOBID) || + (ra.kind == uint8(RESOLVE_REPAIR_RETURN) && !repairRefundOnly)); + if (needsItem) + { + CustodyRow itemRow; + Item* item = NULL; + if (!AhGetCachedReservedItem("item:" + std::to_string(f.auctionId), + f.auctionId, f.sellerGuid, f.itemGuid, + itemRow, item)) + { + sLog.outError("[AHMut] resolve kind %u auction %u missing terminal " + "item; RES_FAILED", + uint32(ra.kind), f.auctionId); + return uint8(RES_FAILED); + } + } + CustodyDeferred def; // [F1] A RESOLVE_CANCELLED_UNLOCK release credits an ONLINE owner's wallet // immediately (in-memory ModifyMoney, non-transactional); only the ledger @@ -3254,23 +3367,42 @@ uint8 AhHandleResolveApply(ResolveApply const& ra) std::string const depKey = "dep:" + std::to_string(f.auctionId); std::string const itemKey = "item:" + std::to_string(f.auctionId); CustodyRow bidRow; - bool const haveBidRow = CustodyLedger::GetSingleLiveBidRow(f.auctionId, bidRow); - // [F2] A REAL winner (curBidderGuid != 0) MUST have exactly one live - // bid reservation. If it is missing/ambiguous, fail-closed (mirror - // AhFinalizeBidOk): pay/deliver NOTHING and roll back, so the winner's - // RESERVED bid never leaks and the resolution re-drives. A BOT win has - // curBidderGuid == 0 and correctly holds NO bid row -> it proceeds. - if (f.curBidderGuid != 0u && !haveBidRow) + bool const realWinner = (f.curBidderGuid != 0u); + bool const displacedPlayer = + (!realWinner && f.priorBidderGuid != 0u); + bool bidRowOk = false; + if (realWinner) + { + bidRowOk = AhFindPriorBidRow(f.auctionId, f.curBidderGuid, + f.curBid, "", bidRow); + } + else if (displacedPlayer) + { + bidRowOk = AhFindPriorBidRow(f.auctionId, f.priorBidderGuid, + f.priorBidAmount, "", bidRow); + } + else + { + std::vector liveRows; + AhLoadLiveBidRows(f.auctionId, liveRows); + bidRowOk = liveRows.empty(); + } + if (!bidRowOk) { CharacterDatabase.RollbackTransaction(); - sLog.outError("[AHMut] PROTOCOL FAULT: RESOLVE_WON winner bid row" - " missing/ambiguous for auction %u - RES_FAILED", f.auctionId); + sLog.outError("[AHMut] PROTOCOL FAULT: RESOLVE_WON bid custody" + " mismatch for auction %u - RES_FAILED", + f.auctionId); return uint8(RES_FAILED); } - if (haveBidRow) + if (realWinner) { CustodyService::CommitGoldLedgerOnly(bidRow.idemKey); } + else if (displacedPlayer) + { + AhRefundPriorBidderFromFacts(f, bidRow.idemKey, def); + } CustodyService::CommitGoldLedgerOnly(depKey); AhSellerPayoutFromFacts(f, def); AhItemToWinnerFromFacts(f, def); @@ -3411,13 +3543,14 @@ static int AhHexNibble(char c) return -1; } -// One peeked ah_worker_journal row (state + kind + decoded facts). +// One peeked ah_worker_journal row and its decoded worker result envelope. struct AhJournalPeek { - uint8 state; - uint8 kind; - MutationFacts facts; - bool factsOk; + uint32 auctionId; + uint8 state; + uint8 kind; + PlayerMutationResult result; + bool payloadOk; }; // [FIX C.2] Tri-state result of a journal peek. mangos `PQuery` returns NULL for @@ -3431,41 +3564,35 @@ enum AhJournalRead AHJRN_QUERY_FAILED = 2 ///< table missing / DB error -> in-doubt, do NOT release }; -// Read one journal row by uuid directly from the shared Character DB. On a NULL -// row query, a `SHOW TABLES` probe distinguishes a genuine absent row (table -// present -> AHJRN_ABSENT, the "worker never committed" release signal, spec 8) -// from a query that could not run (table missing or transient DB error, both -// NULL -> AHJRN_QUERY_FAILED, which the caller leaves in-doubt rather than -// releasing a possibly-committed reservation). The facts BLOB is stored as ASCII -// hex (NUL-safe); decode it in place (mirrors AhJournal::HexDecode + -// MutationFacts::Decode). +// Read one journal row by uuid directly from the shared Character DB. The +// aggregate always returns one row after a successful query, so COUNT cleanly +// distinguishes ABSENT from a NULL query result (DB error/table missing). The +// worker stores a complete PlayerMutationResult as NUL-safe ASCII hex. static AhJournalRead AhReadWorkerJournal(uint64 uuid, AhJournalPeek& out) { QueryResult* q = CharacterDatabase.PQuery( - "SELECT `state`, `kind`, `facts` FROM `ah_worker_journal` WHERE `uuid` = %llu", + "SELECT COUNT(*), COALESCE(MAX(`auction_id`),0), " + "COALESCE(MAX(`state`),0), COALESCE(MAX(`kind`),0), " + "COALESCE(MAX(`facts`),'') FROM `ah_worker_journal` " + "WHERE `uuid`=%llu", static_cast(uuid)); if (q == NULL) { - // Row query returned NULL: probe whether the table exists at all. If the - // probe finds the table, the row is genuinely absent (release). If the - // probe ALSO returns NULL -- table missing OR the DB is unreachable (a - // transient error hits both queries) -- the peek could not be executed; - // report QUERY_FAILED so the caller keeps the pending in-doubt. - QueryResult* probe = CharacterDatabase.Query("SHOW TABLES LIKE 'ah_worker_journal'"); - if (probe == NULL) - { - return AHJRN_QUERY_FAILED; - } - delete probe; - return AHJRN_ABSENT; + return AHJRN_QUERY_FAILED; } Field* fld = q->Fetch(); - out.state = static_cast(fld[0].GetUInt32()); - out.kind = static_cast(fld[1].GetUInt32()); - std::string const hex = fld[2].GetCppString(); + if (fld[0].GetUInt32() == 0u) + { + delete q; + return AHJRN_ABSENT; + } + out.auctionId = fld[1].GetUInt32(); + out.state = static_cast(fld[2].GetUInt32()); + out.kind = static_cast(fld[3].GetUInt32()); + std::string const hex = fld[4].GetCppString(); delete q; - out.factsOk = false; + out.payloadOk = false; if ((hex.size() % 2u) == 0u && !hex.empty()) { std::string bin; @@ -3482,11 +3609,18 @@ static AhJournalRead AhReadWorkerJournal(uint64 uuid, AhJournalPeek& out) } bin.push_back(static_cast((hi << 4) | lo)); } - if (ok && !bin.empty()) + if (ok && bin.size() == PlayerMutationResult::WIRE_SIZE) { ByteBuffer bb; bb.append(reinterpret_cast(bin.data()), bin.size()); - out.factsOk = out.facts.Decode(bb); + PlayerMutationResult stored; + if (stored.Decode(bb) && bb.rpos() == bb.size() && + stored.uuid == uuid && stored.op == out.kind && + stored.facts.auctionId == out.auctionId) + { + out.result = stored; + out.payloadOk = true; + } } } return AHJRN_FOUND; @@ -3497,7 +3631,7 @@ static AhJournalRead AhReadWorkerJournal(uint64 uuid, AhJournalPeek& out) // AhFinalizeRejected's release core, but keyed off the pending (no worker // facts). Accumulates the online in-memory credit into @p onlineCredit so the // caller can undo it if its checked commit fails (X6). -static void AhReleasePendingReservations(PendingMutation const& pm, +static bool AhReleasePendingReservations(PendingMutation const& pm, CustodyDeferred& def, uint32& onlineCredit) { Player* online = sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)); @@ -3532,87 +3666,119 @@ static void AhReleasePendingReservations(PendingMutation const& pm, if (CustodyLedger::Get(pm.itemKey, itemRow) && itemRow.state == CST_RESERVED) { Item* pItem = sAuctionMgr.GetAItem(itemRow.itemGuid); - CustodyLedger::SetState(pm.itemKey, CST_TERMINAL_BACK, static_cast(time(NULL))); - if (pItem) + if (!pItem) { - uint32 const savedItemGuidLow = itemRow.itemGuid; - def.effects.push_back([savedItemGuidLow]() - { - sAuctionMgr.RemoveAItem(savedItemGuidLow); - }); - MailDraft ret(AhMailSubject(pItem->GetEntry(), pItem->GetItemRandomPropertyId(), AUCTION_CANCELED), ""); - ret.AddItem(pItem); - ret.SendMailToInTransaction(MailReceiver(online, ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)), - MailSender(MAIL_AUCTION, 0u, MAIL_STATIONERY_AUCTION), - def, MAIL_CHECK_MASK_COPIED); + sLog.outError( + "[AHMut] reconcile release: escrow item %u missing " + "for uuid " UI64FMTD "; holding reservation", + itemRow.itemGuid, pm.uuid); + return false; } - else + + CustodyLedger::SetState(pm.itemKey, CST_TERMINAL_BACK, + static_cast(time(NULL))); + uint32 const savedItemGuidLow = itemRow.itemGuid; + def.effects.push_back([savedItemGuidLow]() { - sLog.outError("[AHMut] reconcile release: escrow item %u missing for uuid " UI64FMTD - "; ledger-only return", itemRow.itemGuid, pm.uuid); - } + sAuctionMgr.RemoveAItem(savedItemGuidLow); + }); + MailDraft ret(AhMailSubject(pItem->GetEntry(), + pItem->GetItemRandomPropertyId(), + AUCTION_CANCELED), ""); + ret.AddItem(pItem); + ret.SendMailToInTransaction( + MailReceiver(online, + ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)), + MailSender(MAIL_AUCTION, 0u, MAIL_STATIONERY_AUCTION), + def, MAIL_CHECK_MASK_COPIED); } } + return true; } // [SP-2] Absent-journal (or anomalous-state) disposition: release the pending's // reservations, write the applied-record, and consume the slot. One checked txn. -static void AhReconcileReleaseAndConsume(PendingMutation const& pm) +static bool AhReconcileReleaseAndConsume(PendingMutation const& pm) { CustodyDeferred def; uint32 onlineCredit = 0u; CharacterDatabase.BeginTransaction(); - AhReleasePendingReservations(pm, def, onlineCredit); - CustodyService::WriteResolutionApplied(pm.auctionId, pm.uuid); - if (CharacterDatabase.CommitTransactionChecked()) - { - def.run(); - } - else + if (!AhReleasePendingReservations(pm, def, onlineCredit)) { + CharacterDatabase.RollbackTransaction(); if (onlineCredit > 0u) { Player* p = sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)); if (p) { - p->ModifyMoney(-int32(onlineCredit)); // X6: undo the in-memory credit + p->ModifyMoney(-int32(onlineCredit)); } } - sLog.outError("[AHMut] reconcile release commit FAILED for uuid " UI64FMTD, pm.uuid); + return false; } - PendingMutation consumed; - sWorld.GetMutationPending().Take(pm.uuid, consumed); + CustodyService::WriteResolutionApplied(pm.auctionId, pm.uuid); + if (CustodyService::CommitCheckedOrForcedFail("reconcile-release")) + { + def.run(); + PendingMutation consumed; + sWorld.GetMutationPending().Take(pm.uuid, consumed); + return true; + } + + if (onlineCredit > 0u) + { + Player* p = sObjectMgr.GetPlayer( + ObjectGuid(HIGHGUID_PLAYER, pm.playerGuidLow)); + if (p) + { + // X6: undo the in-memory credit. + p->ModifyMoney(-int32(onlineCredit)); + } + } + sLog.outError("[AHMut] reconcile release commit FAILED for uuid " UI64FMTD, + pm.uuid); + return false; } +enum AhReconnectDisposition +{ + AH_RECONNECT_COMPLETE, + AH_RECONNECT_RETRY, + AH_RECONNECT_HELD +}; + +static uint32 const AH_RECONNECT_RETRY_SEC = 5u; +static std::map s_ahReconnectRetries; + // [SP-2] COMMITTED/APPLIED journal disposition: the worker committed the book // but the IPC_PLAYER_RESULT frame was lost. Re-drive the value finalize from // the journal facts. AhHandlePlayerMutationResult is fail-closed against the // custody ledger (a reserve row already flipped terminal by an earlier finalize // makes the cross-check refuse), so a partially/fully-applied finalize re-driven // here can never double-move value or double-mail; it also consumes the pending. -static void AhResolveForwardFromJournal(PendingMutation const& pm, AhJournalPeek const& jp) +static AhReconnectDisposition AhResolveForwardFromJournal( + PendingMutation const& pm, AhJournalPeek const& jp) { - if (!jp.factsOk) + if (!jp.payloadOk || jp.auctionId != pm.auctionId || + jp.result.op != uint8(pm.op & 0xFFu) || + jp.result.status != uint8(MUT_OK)) { - sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": journal committed but facts" - " undecodable; releasing reservation instead", pm.uuid); - AhReconcileReleaseAndConsume(pm); - return; + sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": committed journal " + "payload invalid; holding reservation in-doubt", + pm.uuid); + sWorld.GetMutationPending().Tombstone(pm.uuid); + return AH_RECONNECT_HELD; } - PlayerMutationResult res; - res.uuid = pm.uuid; - res.op = jp.kind; // journal kind == originating opcode low byte - res.status = uint8(MUT_OK); - res.reason = 0; - res.facts = jp.facts; - AhHandlePlayerMutationResult(res); // Takes the pending + fail-closed finalize + // Takes the pending and applies the fail-closed finalize. + AhHandlePlayerMutationResult(jp.result); + return AH_RECONNECT_COMPLETE; } // [SP-2] CANCEL_PREPARED journal disposition (spec 8): a cancel PREPARE lock we // hold with no CONFIRM -> release any cut reservation + tell the worker to ABORT // (unlock the book row), then consume the slot. Mirrors AhFinalizeStale + the // AhHandleCancelPrepared abort frame. -static void AhAbortAndRelease(PendingMutation const& pm) +static bool AhAbortAndRelease(PendingMutation const& pm) { if (!pm.reserveKey.empty()) { @@ -3623,7 +3789,8 @@ static void AhAbortAndRelease(PendingMutation const& pm) CustodyDeferred def; CharacterDatabase.BeginTransaction(); CustodyService::ReleaseGoldToWallet(def, pm.playerGuidLow, online, cutRow.amount, pm.reserveKey); - if (CharacterDatabase.CommitTransactionChecked()) + if (CustodyService::CommitCheckedOrForcedFail( + "reconcile-abort-release")) { def.run(); } @@ -3634,6 +3801,7 @@ static void AhAbortAndRelease(PendingMutation const& pm) online->ModifyMoney(-int32(cutRow.amount)); // X6: undo in-memory credit } sLog.outError("[AHMut] reconcile cut release commit FAILED for uuid " UI64FMTD, pm.uuid); + return false; } } } @@ -3652,12 +3820,68 @@ static void AhAbortAndRelease(PendingMutation const& pm) PendingMutation consumed; sWorld.GetMutationPending().Take(pm.uuid, consumed); + return true; +} + +static AhReconnectDisposition AhReconcilePending( + PendingMutation const& pm) +{ + MutationPendingMap& pend = sWorld.GetMutationPending(); + AhJournalPeek jp; + AhJournalRead const rd = AhReadWorkerJournal(pm.uuid, jp); + + if (rd == AHJRN_QUERY_FAILED) + { + sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": journal peek " + "FAILED (table missing / DB error); holding reservation " + "in-doubt", + pm.uuid); + pend.Tombstone(pm.uuid); + return AH_RECONNECT_RETRY; + } + + if (rd == AHJRN_FOUND) + { + // COMMITTED(1) / APPLIED(3): the worker committed the book. + if (jp.state == 1u || jp.state == 3u) + { + return AhResolveForwardFromJournal(pm, jp); + } + + // CANCEL_PREPARED(4): abort the lock after releasing any cut. + if (jp.state == 4u) + { + if (!jp.payloadOk || jp.auctionId != pm.auctionId || + jp.result.op != uint8(pm.op & 0xFFu) || + jp.result.status != uint8(MUT_PREPARED)) + { + sLog.outError( + "[AHMut] reconcile uuid " UI64FMTD ": cancel-prepared " + "journal payload invalid; holding in-doubt", + pm.uuid); + pend.Tombstone(pm.uuid); + return AH_RECONNECT_HELD; + } + return AhAbortAndRelease(pm) ? AH_RECONNECT_COMPLETE + : AH_RECONNECT_RETRY; + } + + // Any other present state for a mangosd pending is anomalous. The + // mangosd and worker UUID spaces are disjoint, so release safely. + sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": unexpected journal " + "state %u; releasing reservation", + pm.uuid, uint32(jp.state)); + } + + // A genuinely absent row means the worker never committed this mutation. + return AhReconcileReleaseAndConsume(pm) ? AH_RECONNECT_COMPLETE + : AH_RECONNECT_RETRY; } // [SP-2] Walk every in-flight pending against the shared worker journal on the -// service-just-became-active edge (spec 8). Per uuid: COMMITTED/APPLIED => -// finalize-forward; CANCEL_PREPARED => abort + release; absent (or an anomalous -// present state) => release the reservation. Each disposition consumes its slot. +// service-just-became-active edge (spec 8). Failed local/query dispositions are +// queued for the steady-state retry tick; malformed durable payloads remain +// held for operator repair. void AhReconcileOnReconnect() { MutationPendingMap& pend = sWorld.GetMutationPending(); @@ -3673,44 +3897,48 @@ void AhReconcileOnReconnect() for (size_t i = 0; i < inflight.size(); ++i) { PendingMutation const& pm = inflight[i]; - AhJournalPeek jp; - AhJournalRead const rd = AhReadWorkerJournal(pm.uuid, jp); - - // [FIX C.2] The journal peek could not be executed (table missing or a - // transient DB error): do NOT release -- the worker may have committed - // this mutation. Leave the pending in-doubt (tombstone, reservation held) - // so a later reconcile or an operator resolves it. Consumes no slot. - if (rd == AHJRN_QUERY_FAILED) - { - sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": journal peek FAILED" - " (table missing / DB error); holding reservation in-doubt", - pm.uuid); - sWorld.GetMutationPending().Tombstone(pm.uuid); + AhReconnectDisposition const disposition = AhReconcilePending(pm); + if (disposition == AH_RECONNECT_RETRY) + { + s_ahReconnectRetries[pm.uuid] = + uint32(time(NULL)) + AH_RECONNECT_RETRY_SEC; + } + else + { + s_ahReconnectRetries.erase(pm.uuid); + } + } +} + +void AhProcessReconnectRetryQueue(uint32 nowSec) +{ + MutationPendingMap& pend = sWorld.GetMutationPending(); + std::map::iterator itr = s_ahReconnectRetries.begin(); + while (itr != s_ahReconnectRetries.end()) + { + if (itr->second > nowSec) + { + ++itr; continue; } - if (rd == AHJRN_FOUND) + std::map::iterator current = itr++; + PendingMutation pm; + if (!pend.Peek(current->first, pm)) { - // COMMITTED(1) / APPLIED(3): the worker committed the book -> value forward. - if (jp.state == 1u || jp.state == 3u) - { - AhResolveForwardFromJournal(pm, jp); - continue; - } - // CANCEL_PREPARED(4): a stuck cancel lock -> abort + release the cut. - if (jp.state == 4u) - { - AhAbortAndRelease(pm); - continue; - } - // Any other present state for a mangosd pending is anomalous (the - // mangosd/worker uuid spaces are disjoint) -> release, never leak gold. - sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": unexpected journal state %u;" - " releasing reservation", pm.uuid, uint32(jp.state)); - } - // Row genuinely absent (AHJRN_ABSENT), or an anomalous present state: the - // worker never committed -> release the reservation (forward-only, spec 8). - AhReconcileReleaseAndConsume(pm); + s_ahReconnectRetries.erase(current); + continue; + } + + AhReconnectDisposition const disposition = AhReconcilePending(pm); + if (disposition == AH_RECONNECT_RETRY) + { + current->second = nowSec + AH_RECONNECT_RETRY_SEC; + } + else + { + s_ahReconnectRetries.erase(current); + } } } diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index 672cbf835..aaac99253 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -1414,7 +1414,9 @@ void World::Update(uint32 diff) // SP-2: retry failed value-finalizes and age un-answered player // mutations into in-doubt tombstones (forward-only; never rolls // back). Cheap no-op when both queues are empty. - AhProcessRedriveQueue(uint32(time(NULL))); + uint32 const mutationNowSec = uint32(time(NULL)); + AhProcessRedriveQueue(mutationNowSec); + AhProcessReconnectRetryQueue(mutationNowSec); } // Expire processed-uuid dedup entries. UNCONDITIONAL: the dedup cache diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 8a20e56e8..a72a3d671 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -1,6 +1,7 @@ #include "Utilities/Errors.h" #include #include "MangosdTest.h" +#include "Config/Config.h" #include "Log.h" #include "Database/DatabaseEnv.h" #include "Chat.h" @@ -26,10 +27,17 @@ #include "WorldSession.h" #include "PlayerMutations.h" #include +#include #include #include #include +#ifdef _WIN32 +#include +#else +#include +#endif + bool AhBuildCancelPrepareForward(MutationPendingMap& pending, uint32 playerGuidLow, uint32 auctionId, uint64 uuid, uint32 sentSec, IpcMessage& out); @@ -628,6 +636,81 @@ static bool RunPureCustodyReconcilerTests() return pass; } +static Item* TestCreateCachedAuctionItem(uint32 itemId, uint32 ownerGuid) +{ + Item* item = Item::CreateItem(itemId, 1); + if (!item) + { + return NULL; + } + + item->SetOwnerGuid(ObjectGuid(HIGHGUID_PLAYER, ownerGuid)); + CharacterDatabase.BeginTransaction(); + item->SaveToDB(); + if (!CharacterDatabase.CommitTransactionChecked()) + { + delete item; + return NULL; + } + + sAuctionMgr.AddAItem(item); + return item; +} + +static bool TestArmCustodyCommitFailure(char const* phase, + std::string& originalConfig, + std::string& testConfig) +{ + originalConfig = sConfig.GetFilename(); + char configPath[] = "mangosd-custody-selftest-XXXXXX"; +#ifdef _WIN32 + if (_mktemp_s(configPath, sizeof(configPath)) != 0) + { + return false; + } + + FILE* config = NULL; + if (fopen_s(&config, configPath, "w") != 0) + { + return false; + } +#else + int const configHandle = mkstemp(configPath); + if (configHandle == -1) + { + return false; + } + + FILE* config = fdopen(configHandle, "w"); + if (!config) + { + close(configHandle); + remove(configPath); + return false; + } +#endif + fprintf(config, + "[MangosdConf]\n" + "AH.Service.CustodyFailCommitAt = \"%s\"\n", + phase); + fclose(config); + testConfig = configPath; + if (!sConfig.SetSource(testConfig.c_str())) + { + remove(testConfig.c_str()); + return false; + } + return true; +} + +static bool TestRestoreConfig(std::string const& originalConfig, + std::string const& testConfig) +{ + bool const restored = sConfig.SetSource(originalConfig.c_str()); + remove(testConfig.c_str()); + return restored; +} + /// Self-test for Database::CommitTransactionChecked(): proves the runtime /// (async-enabled) path is synchronous, durable and returns the REAL result. /// Returns 0 on pass, non-zero on fail. @@ -2655,15 +2738,31 @@ static int RunAhMutResultTest() { bool pass = true; CharacterDatabase.AllowAsyncTransactions(); - sObjectMgr.SetHighestGuids(); // mail ids collide otherwise (RunMailTest) // Clean slate from any prior run (auction-id scoped -- covers both the // test:mut* keys and the hardcoded dep:/item: keys the buyout finalize uses). CharacterDatabase.DirectExecute( - "DELETE FROM `custody_ledger` WHERE `auction_id` IN (990001,990002,990003,990004,990005)"); + "DELETE FROM `custody_ledger` WHERE `auction_id` IN " + "(990001,990002,990003,990004,990005,990006,990007)"); + CharacterDatabase.DirectExecute( + "DELETE ii FROM `item_instance` ii " + "JOIN `mail_items` mi ON mi.`item_guid`=ii.`guid` " + "JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE mi FROM `mail_items` mi JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); CharacterDatabase.DirectExecute( "DELETE FROM `mail` WHERE `receiver` IN (1,2) AND `subject` LIKE '19019:%'"); + sObjectMgr.SetHighestGuids(); // mail and item ids collide otherwise + sObjectMgr.LoadItemPrototypes(); + if (!ObjectMgr::GetItemPrototype(19019u)) + { + printf("ahmutresult FAIL: item prototype 19019 missing\n"); + return 2; + } + // Seed the offline recipient (guid 1) with an account + durable money so the // account-guarded AH mail path delivers and the offline gold re-credit UPDATE // has a row to hit. World data is NOT loaded under -t, so GetPlayer(1) is @@ -2828,6 +2927,14 @@ static int RunAhMutResultTest() // The worker removed the row at buyout: seller paid, item to winner, // buyer reserve committed as proceeds, deposit returned, remainder released. { + Item* winItem = TestCreateCachedAuctionItem(19019u, 1u); + if (!winItem) + { + printf("ahmutresult FAIL: create buyout-win escrow item\n"); + return 2; + } + uint32 const winItemGuid = winItem->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); CharacterDatabase.PExecute( "INSERT INTO `custody_ledger` " @@ -2835,7 +2942,7 @@ static int RunAhMutResultTest() "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " "VALUES ('test:mut:bowin',0,1,0,99999,0,1000,0,990003,0,0)," " ('dep:990003',0,0,0,1,0,50,0,990003,0,0)," - " ('item:990003',1,3,0,1,0,0,424243,990003,0,0)"); + " ('item:990003',1,3,0,1,0,0,%u,990003,0,0)", winItemGuid); if (!CharacterDatabase.CommitTransactionChecked()) { printf("ahmutresult FAIL: seed commit (buyout-win)\n"); @@ -2863,7 +2970,7 @@ static int RunAhMutResultTest() res.facts = MutationFacts(); res.facts.auctionId = 990003u; res.facts.houseId = 7; - res.facts.itemGuid = 424243u; + res.facts.itemGuid = winItemGuid; res.facts.itemTemplate = 19019u; res.facts.randomPropertyId = 0; res.facts.sellerGuid = 1u; // seller has an account -> gets payout mail @@ -2977,13 +3084,21 @@ static int RunAhMutResultTest() // ---- Part A5: MUT_OK cancel CONFIRM finalizes seller return + deposit ---- { + Item* cancelItem = TestCreateCachedAuctionItem(19019u, 1u); + if (!cancelItem) + { + printf("ahmutresult FAIL: create cancel escrow item\n"); + return 2; + } + uint32 const cancelItemGuid = cancelItem->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); CharacterDatabase.PExecute( "INSERT INTO `custody_ledger` " "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " "VALUES ('dep:990005',0,0,0,1,0,32,0,990005,0,0)," - " ('item:990005',1,3,0,1,0,0,424245,990005,0,0)"); + " ('item:990005',1,3,0,1,0,0,%u,990005,0,0)", cancelItemGuid); if (!CharacterDatabase.CommitTransactionChecked()) { printf("ahmutresult FAIL: seed commit (cancel-confirm)\n"); @@ -3011,7 +3126,7 @@ static int RunAhMutResultTest() res.facts = MutationFacts(); res.facts.auctionId = 990005u; res.facts.houseId = 7; - res.facts.itemGuid = 424245u; + res.facts.itemGuid = cancelItemGuid; res.facts.itemTemplate = 19019u; res.facts.randomPropertyId = 0; res.facts.sellerGuid = 1u; @@ -3036,7 +3151,155 @@ static int RunAhMutResultTest() } } - // ---- Part B: MUT_REJECTED buyout -> ReleaseGoldToWallet (offline) ---- + // ---- Part B1: rejected sell returns the item and the durable deposit ---- + { + Item* rejectedItem = TestCreateCachedAuctionItem(19019u, 1u); + if (!rejectedItem) + { + printf("ahmutresult FAIL: create rejected-sell escrow item\n"); + return 2; + } + uint32 const rejectedItemGuid = rejectedItem->GetGUIDLow(); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('dep:990006',0,0,0,1,0,73,0,990006,0,0)," + " ('item:990006',1,3,0,1,0,0,%u,990006,0,0)", + rejectedItemGuid); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahmutresult FAIL: seed commit (rejected sell)\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = 0xABull; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_SELL); + pm.auctionId = 990006u; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + // Production sell pending stores its deposit in depKey. + pm.reservedAmount = 0u; + pm.reserveKey.clear(); + pm.itemKey = "item:990006"; + pm.depKey = "dep:990006"; + pend.Register(pm); + + uint64 const before = readMoney(); + PlayerMutationResult res; + res.uuid = pm.uuid; + res.op = uint8(IPC_PLAYER_SELL & 0xFFu); + res.status = uint8(MUT_REJECTED); + res.reason = uint8(AUCTION_ERR_DATABASE); + res.facts = MutationFacts(); + res.facts.auctionId = pm.auctionId; + res.facts.houseId = 7u; + AhHandlePlayerMutationResult(res); + + if (readMoney() != before + 73u) + { + printf("ahmutresult FAIL: rejected sell did not refund durable " + "deposit amount\n"); + pass = false; + } + if (rowState("dep:990006") != CST_TERMINAL_BACK || + rowState("item:990006") != CST_TERMINAL_BACK) + { + printf("ahmutresult FAIL: rejected sell custody not " + "TERMINAL_BACK\n"); + pass = false; + } + std::unique_ptr returned(CharacterDatabase.PQuery( + "SELECT 1 FROM `mail_items` WHERE `receiver`=1 AND `item_guid`=%u", + rejectedItemGuid)); + if (!returned) + { + printf("ahmutresult FAIL: rejected sell item-return mail " + "missing\n"); + pass = false; + } + } + + // ---- Part B2: missing sell escrow item holds both rows for retry ---- + { + Item* missingItem = TestCreateCachedAuctionItem(19019u, 1u); + if (!missingItem) + { + printf("ahmutresult FAIL: create missing-item retry fixture\n"); + return 2; + } + uint32 const missingItemGuid = missingItem->GetGUIDLow(); + sAuctionMgr.RemoveAItem(missingItemGuid); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('dep:990007',0,0,0,1,0,74,0,990007,0,0)," + " ('item:990007',1,3,0,1,0,0,%u,990007,0,0)", + missingItemGuid); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahmutresult FAIL: seed commit " + "(missing rejected-sell item)\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = 0xACull; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_SELL); + pm.auctionId = 990007u; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 0u; + pm.reserveKey.clear(); + pm.itemKey = "item:990007"; + pm.depKey = "dep:990007"; + pend.Register(pm); + + uint64 const before = readMoney(); + PlayerMutationResult res; + res.uuid = pm.uuid; + res.op = uint8(IPC_PLAYER_SELL & 0xFFu); + res.status = uint8(MUT_REJECTED); + res.reason = uint8(AUCTION_ERR_DATABASE); + res.facts = MutationFacts(); + res.facts.auctionId = pm.auctionId; + res.facts.houseId = 7u; + AhHandlePlayerMutationResult(res); + + if (readMoney() != before || rowState("dep:990007") != CST_RESERVED || + rowState("item:990007") != CST_RESERVED) + { + printf("ahmutresult FAIL: missing rejected-sell item moved " + "conserved value\n"); + pass = false; + } + + sAuctionMgr.AddAItem(missingItem); + AhProcessRedriveQueue(uint32(time(NULL)) + 6u); + std::unique_ptr returned(CharacterDatabase.PQuery( + "SELECT 1 FROM `mail_items` WHERE `receiver`=1 " + "AND `item_guid`=%u", + missingItemGuid)); + if (readMoney() != before + 74u || + rowState("dep:990007") != CST_TERMINAL_BACK || + rowState("item:990007") != CST_TERMINAL_BACK || !returned || + sAuctionMgr.GetAItem(missingItemGuid)) + { + printf("ahmutresult FAIL: missing-item redrive did not conserve " + "and return value\n"); + pass = false; + } + } + + // ---- Part B3: MUT_REJECTED buyout -> ReleaseGoldToWallet (offline) ---- { CharacterDatabase.BeginTransaction(); CharacterDatabase.PExecute( @@ -3234,7 +3497,16 @@ static int RunAhMutResultTest() // Clean up. CharacterDatabase.DirectExecute( - "DELETE FROM `custody_ledger` WHERE `auction_id` IN (990001,990002,990003,990004,990005)"); + "DELETE FROM `custody_ledger` WHERE `auction_id` IN " + "(990001,990002,990003,990004,990005,990006,990007)"); + CharacterDatabase.DirectExecute( + "DELETE ii FROM `item_instance` ii " + "JOIN `mail_items` mi ON mi.`item_guid`=ii.`guid` " + "JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE mi FROM `mail_items` mi JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); CharacterDatabase.DirectExecute( "DELETE FROM `mail` WHERE `receiver` IN (1,2) AND `subject` LIKE '19019:%'"); CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); @@ -3252,25 +3524,44 @@ static int RunAhMutResultTest() /// REPAIR_RETURN) against SEEDED custody rows -- no live worker, no world data. /// Asserts each kind's ledger flips + value mails, the resolve: /// applied-record, and that a SECOND apply returns RES_DUPLICATE without -/// double-applying. Recipients use guid 1 (seeded offline with an account so the -/// account-guarded AH mail path delivers -- see RunAhMutResultTest). World data -/// is NOT loaded under -t, so GetAItem() is always NULL: the item legs take the -/// escrow-cache-miss (ledger-only) branch and no physical item mail is asserted. +/// double-applying. Recipients use guid 1 or 2, seeded offline with accounts so +/// the account-guarded AH mail paths deliver. Item prototypes are loaded only +/// for terminal-path fixtures that intentionally provide a cached escrow +/// object. /// Returns 0 on pass. static int RunAhResolveTest() { bool pass = true; CharacterDatabase.AllowAsyncTransactions(); - sObjectMgr.SetHighestGuids(); // mail ids collide otherwise (RunMailTest) CharacterDatabase.DirectExecute( - "DELETE FROM `custody_ledger` WHERE `auction_id` IN (991001,991002,991003,991004,991005)"); + "DELETE FROM `custody_ledger` WHERE `auction_id` IN " + "(991001,991002,991003,991004,991005,991006)"); CharacterDatabase.DirectExecute( - "DELETE FROM `mail` WHERE `receiver`=1 AND `subject` LIKE '19019:%'"); - CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + "DELETE ii FROM `item_instance` ii " + "JOIN `mail_items` mi ON mi.`item_guid`=ii.`guid` " + "JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE mi FROM `mail_items` mi JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver` IN (1,2) " + "AND `subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE FROM `characters` WHERE `guid` IN (1,2)"); CharacterDatabase.DirectExecute( "INSERT INTO `characters` (`guid`,`account`,`name`,`money`) " - "VALUES (1, 1, 'AhResTestRcv', 100000)"); + "VALUES (1, 1, 'AhResTestRcv', 100000)," + " (2, 2, 'AhResSeller', 100000)"); + + sObjectMgr.SetHighestGuids(); // mail and item ids collide otherwise + sObjectMgr.LoadItemPrototypes(); + if (!ObjectMgr::GetItemPrototype(19019u)) + { + printf("ahresolve FAIL: item prototype 19019 missing\n"); + return 2; + } auto readMoney = []() -> uint64 { @@ -3292,7 +3583,7 @@ static int RunAhResolveTest() return res ? res->Fetch()[0].GetUInt64() : 0; }; - // ---- RESOLVE_WON: bid + dep + item terminal; seller payout mail ---- + // ---- RESOLVE_WON: missing item cache must hold every value leg ---- { CharacterDatabase.BeginTransaction(); CharacterDatabase.PExecute( @@ -3324,46 +3615,47 @@ static int RunAhResolveTest() ra.facts.curBidderGuid = 99999u; // offline-nobody winner ra.facts.buyout = 800u; - if (AhHandleResolveApply(ra) != uint8(RES_APPLIED)) + if (AhHandleResolveApply(ra) != uint8(RES_FAILED)) { - printf("ahresolve FAIL: WON not RES_APPLIED\n"); + printf("ahresolve FAIL: missing-item WON not RES_FAILED\n"); pass = false; } - if (!CustodyService::ResolutionApplied(0xB1ull)) + if (CustodyService::ResolutionApplied(0xB1ull)) { - printf("ahresolve FAIL: WON applied-record missing\n"); + printf("ahresolve FAIL: missing-item WON wrote applied-record\n"); pass = false; } - if (rowState("bid:991001:1") != 1u) + if (rowState("bid:991001:1") != CST_RESERVED) { - printf("ahresolve FAIL: WON bid row not TERMINAL_OK\n"); + printf("ahresolve FAIL: missing-item WON consumed bid\n"); pass = false; } - if (rowState("dep:991001") != 1u) + if (rowState("dep:991001") != CST_RESERVED) { - printf("ahresolve FAIL: WON dep row not TERMINAL_OK\n"); + printf("ahresolve FAIL: missing-item WON consumed deposit\n"); pass = false; } - if (rowState("item:991001") != 1u) + if (rowState("item:991001") != CST_RESERVED) { - printf("ahresolve FAIL: WON item row not TERMINAL_OK\n"); + printf("ahresolve FAIL: missing-item WON terminalized item " + "custody\n"); pass = false; } - if (mailCount(850u, "19019:0:2") != 1u) // profit = 800+50-cut(0 under -t) + if (mailCount(850u, "19019:0:2") != 0u) { - printf("ahresolve FAIL: WON seller payout mail missing\n"); + printf("ahresolve FAIL: missing-item WON paid seller\n"); pass = false; } - // Duplicate: RES_DUPLICATE, no second payout mail, rows unchanged. - if (AhHandleResolveApply(ra) != uint8(RES_DUPLICATE)) + if (AhHandleResolveApply(ra) != uint8(RES_FAILED)) { - printf("ahresolve FAIL: WON second apply not RES_DUPLICATE\n"); + printf("ahresolve FAIL: missing-item WON retry was not " + "RES_FAILED\n"); pass = false; } - if (mailCount(850u, "19019:0:2") != 1u) + if (mailCount(850u, "19019:0:2") != 0u) { - printf("ahresolve FAIL: WON duplicate double-applied payout mail\n"); + printf("ahresolve FAIL: missing-item WON retry paid seller\n"); pass = false; } } @@ -3376,13 +3668,22 @@ static int RunAhResolveTest() // applied-record. (A legit BOT win has curBidderGuid == 0 and no bid row -- // that proceed case is exercised by the REPAIR/bot-win paths.) { + Item* missingBidItem = TestCreateCachedAuctionItem(19019u, 1u); + if (!missingBidItem) + { + printf("ahresolve FAIL: create missing-bid escrow item\n"); + return 2; + } + uint32 const missingBidItemGuid = missingBidItem->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); CharacterDatabase.PExecute( "INSERT INTO `custody_ledger` " "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " "VALUES ('dep:991005',0,0,0,1,0,30,0,991005,0,0)," - " ('item:991005',1,3,0,1,0,0,424245,991005,0,0)"); + " ('item:991005',1,3,0,1,0,0,%u,991005,0,0)", + missingBidItemGuid); if (!CharacterDatabase.CommitTransactionChecked()) { printf("ahresolve FAIL: seed commit (won fail-closed)\n"); @@ -3395,7 +3696,7 @@ static int RunAhResolveTest() ra.facts = MutationFacts(); ra.facts.auctionId = 991005u; ra.facts.houseId = 7; - ra.facts.itemGuid = 424245u; + ra.facts.itemGuid = missingBidItemGuid; ra.facts.itemTemplate = 19019u; ra.facts.randomPropertyId = 0; ra.facts.sellerGuid = 1u; // would get a payout mail if wrongly paid @@ -3430,17 +3731,34 @@ static int RunAhResolveTest() printf("ahresolve FAIL: WON-fail-closed item row not rolled back\n"); pass = false; } + + if (Item* leftover = sAuctionMgr.GetAItem(missingBidItemGuid)) + { + sAuctionMgr.RemoveAItem(missingBidItemGuid); + delete leftover; + } + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", missingBidItemGuid); } // ---- RESOLVE_EXPIRED_NOBID: deposit forfeit + item returned ---- { + Item* expiredItem = TestCreateCachedAuctionItem(19019u, 1u); + if (!expiredItem) + { + printf("ahresolve FAIL: create expired escrow item\n"); + return 2; + } + uint32 const expiredItemGuid = expiredItem->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); CharacterDatabase.PExecute( "INSERT INTO `custody_ledger` " "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " "VALUES ('dep:991002',0,0,0,1,0,40,0,991002,0,0)," - " ('item:991002',1,3,0,1,0,0,424244,991002,0,0)"); + " ('item:991002',1,3,0,1,0,0,%u,991002,0,0)", + expiredItemGuid); if (!CharacterDatabase.CommitTransactionChecked()) { printf("ahresolve FAIL: seed commit (expired)\n"); @@ -3453,7 +3771,7 @@ static int RunAhResolveTest() ra.facts = MutationFacts(); ra.facts.auctionId = 991002u; ra.facts.houseId = 7; - ra.facts.itemGuid = 424244u; + ra.facts.itemGuid = expiredItemGuid; ra.facts.itemTemplate = 19019u; ra.facts.sellerGuid = 1u; ra.facts.deposit = 40u; @@ -3485,6 +3803,96 @@ static int RunAhResolveTest() } } + // ---- RESOLVE_WON bot buyout: refund the displaced real bidder ---- + { + Item* botWinItem = TestCreateCachedAuctionItem(19019u, 2u); + if (!botWinItem) + { + printf("ahresolve FAIL: create bot-win escrow item\n"); + return 2; + } + uint32 const botWinItemGuid = botWinItem->GetGUIDLow(); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('bid:991006:1',0,1,0,1,0,401,0,991006,0,0)," + " ('dep:991006',0,0,0,2,0,50,0,991006,0,0)," + " ('item:991006',1,3,0,2,0,0,%u,991006,0,0)", botWinItemGuid); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahresolve FAIL: seed commit (bot buyout)\n"); + return 2; + } + + ResolveApply ra; + ra.uuid = 0xB6ull; + ra.kind = uint8(RESOLVE_WON); + ra.facts = MutationFacts(); + ra.facts.auctionId = 991006u; + ra.facts.houseId = 7u; + ra.facts.itemGuid = botWinItemGuid; + ra.facts.itemTemplate = 19019u; + ra.facts.randomPropertyId = 0; + ra.facts.sellerGuid = 2u; + ra.facts.deposit = 50u; + ra.facts.effectiveBid = 800u; + ra.facts.curBid = 800u; + ra.facts.curBidderGuid = 0u; + ra.facts.priorBidderGuid = 1u; + ra.facts.priorBidAmount = 401u; + ra.facts.buyout = 800u; + + if (AhHandleResolveApply(ra) != uint8(RES_APPLIED)) + { + printf("ahresolve FAIL: bot buyout not RES_APPLIED\n"); + pass = false; + } + if (rowState("bid:991006:1") != CST_TERMINAL_BACK) + { + printf("ahresolve FAIL: bot buyout consumed displaced player " + "bid\n"); + pass = false; + } + if (mailCount(401u, "19019:0:0") != 1u) + { + printf("ahresolve FAIL: bot buyout prior-bidder refund missing\n"); + pass = false; + } + if (!CustodyService::ResolutionApplied(ra.uuid)) + { + printf("ahresolve FAIL: bot buyout applied-record missing\n"); + pass = false; + } + std::unique_ptr durableItem(CharacterDatabase.PQuery( + "SELECT 1 FROM `item_instance` WHERE `guid`=%u", botWinItemGuid)); + if (rowState("item:991006") != CST_TERMINAL_OK || + sAuctionMgr.GetAItem(botWinItemGuid) || durableItem) + { + printf("ahresolve FAIL: bot buyout did not consume terminal " + "item\n"); + pass = false; + } + if (AhHandleResolveApply(ra) != uint8(RES_DUPLICATE) || + mailCount(401u, "19019:0:0") != 1u) + { + printf("ahresolve FAIL: bot buyout duplicate double-refunded\n"); + pass = false; + } + + if (Item* leftover = sAuctionMgr.GetAItem(botWinItemGuid)) + { + sAuctionMgr.RemoveAItem(botWinItemGuid); + delete leftover; + } + CharacterDatabase.DirectPExecute( + "DELETE FROM `mail_items` WHERE `item_guid`=%u", botWinItemGuid); + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", botWinItemGuid); + } + // ---- RESOLVE_CANCELLED_UNLOCK: cut released to seller, no item/bid move ---- // The cut key is uuid-salted in production ("cut::"), so this // exercises the by-auction cut scan (AhFindLiveCutRow), not a point key. @@ -3604,10 +4012,21 @@ static int RunAhResolveTest() // Clean up. CharacterDatabase.DirectExecute( - "DELETE FROM `custody_ledger` WHERE `auction_id` IN (991001,991002,991003,991004,991005)"); + "DELETE FROM `custody_ledger` WHERE `auction_id` IN " + "(991001,991002,991003,991004,991005,991006)"); CharacterDatabase.DirectExecute( - "DELETE FROM `mail` WHERE `receiver`=1 AND `subject` LIKE '19019:%'"); - CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + "DELETE ii FROM `item_instance` ii " + "JOIN `mail_items` mi ON mi.`item_guid`=ii.`guid` " + "JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE mi FROM `mail_items` mi JOIN `mail` m ON m.`id`=mi.`mail_id` " + "WHERE m.`receiver` IN (1,2) AND m.`subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver` IN (1,2) " + "AND `subject` LIKE '19019:%'"); + CharacterDatabase.DirectExecute( + "DELETE FROM `characters` WHERE `guid` IN (1,2)"); if (pass) { @@ -4578,6 +4997,441 @@ static int RunAhRepairRecoveryTest() return 2; } +/// Reconcile-on-reconnect conservation regressions. A failed local release +/// commit must retain the pending mutation, and committed worker facts that +/// cannot be decoded must hold the reservation in-doubt rather than release it. +static int RunAhReconcileTest() +{ + bool pass = true; + CharacterDatabase.AllowAsyncTransactions(); + + uint32 const commitAuction = 993101u; + uint32 const malformedAuction = 993102u; + uint32 const validAuction = 993104u; + uint64 const commitUuid = 0xC101ull; + uint64 const malformedUuid = 0xC102ull; + uint64 const validUuid = 0xC104ull; + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` " + "WHERE `auction_id` IN (993101,993102,993104) " + "OR `idem_key` IN ('resolve:%llu','resolve:%llu','resolve:%llu')", + static_cast(commitUuid), + static_cast(malformedUuid), + static_cast(validUuid)); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` " + "WHERE `auction_id` IN (993101,993102,993104) " + "OR `uuid` IN (%llu,%llu,%llu)", + static_cast(commitUuid), + static_cast(malformedUuid), + static_cast(validUuid)); + CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + CharacterDatabase.DirectExecute( + "INSERT INTO `characters` (`guid`,`account`,`name`,`money`) " + "VALUES (1,1,'AhReconTest',100000)"); + + auto readMoney = []() -> uint64 + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT `money` FROM `characters` WHERE `guid`=1")); + return res ? res->Fetch()[0].GetUInt64() : 0u; + }; + auto rowState = [](char const* key) -> uint32 + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT `state` FROM `custody_ledger` WHERE `idem_key`='%s'", key)); + return res ? res->Fetch()[0].GetUInt32() : 255u; + }; + auto rowAmount = [](char const* key) -> uint32 + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT `amount` FROM `custody_ledger` WHERE `idem_key`='%s'", + key)); + return res ? res->Fetch()[0].GetUInt32() : 0u; + }; + + MutationPendingMap& pend = sWorld.GetMutationPending(); + + // ---- Part 1: checked release failure retains pending + reservation ---- + { + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:reconcile:commit',0,1,0,1,0,333,0,%u,0,0)", + commitAuction); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahreconcile FAIL: commit-failure seed\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = commitUuid; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_BID); + pm.auctionId = commitAuction; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 333u; + pm.reserveKey = "test:reconcile:commit"; + pm.itemKey.clear(); + pm.depKey.clear(); + pend.Register(pm); + + uint64 const before = readMoney(); + std::string originalConfig; + std::string testConfig; + if (!TestArmCustodyCommitFailure( + "reconcile-release", originalConfig, testConfig)) + { + TestRestoreConfig(originalConfig, testConfig); + printf("ahreconcile FAIL: could not arm checked-commit failure\n"); + return 2; + } + AhReconcileOnReconnect(); + if (!TestRestoreConfig(originalConfig, testConfig)) + { + printf("ahreconcile FAIL: could not restore configuration\n"); + return 2; + } + + PendingMutation held; + if (!pend.Peek(commitUuid, held) || pend.Size() != 1u) + { + printf("ahreconcile FAIL: failed release consumed pending " + "mutation\n"); + pass = false; + } + if (rowState("test:reconcile:commit") != CST_RESERVED || + readMoney() != before || + CustodyService::ResolutionApplied(commitUuid)) + { + printf("ahreconcile FAIL: failed release moved conserved gold\n"); + pass = false; + } + + AhProcessReconnectRetryQueue(uint32(time(NULL)) + 6u); + if (pend.Peek(commitUuid, held) || + rowState("test:reconcile:commit") != CST_TERMINAL_BACK || + readMoney() != before + 333u || + !CustodyService::ResolutionApplied(commitUuid)) + { + printf("ahreconcile FAIL: retained release did not apply once " + "on retry\n"); + pass = false; + } + } + + // ---- Part 2: exact worker journal envelope replays forward ---- + { + PlayerMutationResult journalRes; + journalRes.uuid = validUuid; + journalRes.op = uint8(IPC_PLAYER_BID & 0xFFu); + journalRes.status = uint8(MUT_OK); + journalRes.reason = 0u; + journalRes.facts = MutationFacts(); + journalRes.facts.auctionId = validAuction; + journalRes.facts.houseId = 7u; + journalRes.facts.itemCount = 1u; + journalRes.facts.sellerGuid = 2u; + journalRes.facts.effectiveBid = 500u; + journalRes.facts.priorBidderGuid = 1u; + journalRes.facts.priorBidAmount = 300u; + journalRes.facts.curBidderGuid = 1u; + journalRes.facts.curBid = 500u; + + ByteBuffer bb; + journalRes.Encode(bb); + std::string const factsHex = TestHexEncode(bb); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`," + "`beneficiary_guid`,`amount`,`item_guid`,`auction_id`," + "`created_time`,`resolved_time`) VALUES " + "('test:reconcile:valid-prior',0,1,0,1,0,300,0,%u,0,0)," + "('test:reconcile:valid-delta',0,1,0,1,0,200,0,%u,0,0)", + validAuction, validAuction); + CharacterDatabase.PExecute( + "INSERT INTO `ah_worker_journal` " + "(`uuid`,`auction_id`,`kind`,`state`,`facts`," + "`created_time`,`resolved_time`) " + "VALUES (%llu,%u,%u,1,'%s',0,0)", + static_cast(validUuid), validAuction, + uint32(IPC_PLAYER_BID & 0xFFu), factsHex.c_str()); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahreconcile FAIL: valid worker-envelope seed\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = validUuid; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_BID); + pm.auctionId = validAuction; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 200u; + pm.reserveKey = "test:reconcile:valid-delta"; + pm.itemKey.clear(); + pm.depKey.clear(); + pend.Register(pm); + + AhReconcileOnReconnect(); + + PendingMutation held; + if (pend.Peek(validUuid, held) || + rowState("test:reconcile:valid-prior") != CST_RESERVED || + rowAmount("test:reconcile:valid-prior") != 500u || + rowState("test:reconcile:valid-delta") != CST_TERMINAL_OK) + { + printf("ahreconcile FAIL: valid worker envelope did not replay\n"); + pass = false; + } + } + + // ---- Part 3: malformed committed facts hold/tombstone, never release ---- + { + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=993101"); + CharacterDatabase.DirectExecute( + "UPDATE `characters` SET `money`=100000 WHERE `guid`=1"); + + PlayerMutationResult malformedRes; + malformedRes.uuid = malformedUuid + 1u; + malformedRes.op = uint8(IPC_PLAYER_BID & 0xFFu); + malformedRes.status = uint8(MUT_OK); + malformedRes.reason = 0u; + malformedRes.facts = MutationFacts(); + malformedRes.facts.auctionId = malformedAuction; + malformedRes.facts.curBidderGuid = 1u; + malformedRes.facts.effectiveBid = 444u; + malformedRes.facts.curBid = 444u; + ByteBuffer bb; + malformedRes.Encode(bb); + std::string const malformedHex = TestHexEncode(bb); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:reconcile:malformed',0,1,0,1,0,444,0,%u,0,0)", + malformedAuction); + CharacterDatabase.PExecute( + "INSERT INTO `ah_worker_journal` " + "(`uuid`,`auction_id`,`kind`,`state`,`facts`," + "`created_time`,`resolved_time`) " + "VALUES (%llu,%u,%u,1,'%s',0,0)", + static_cast(malformedUuid), malformedAuction, + uint32(IPC_PLAYER_BID & 0xFFu), malformedHex.c_str()); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahreconcile FAIL: malformed-facts seed\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = malformedUuid; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_BID); + pm.auctionId = malformedAuction; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 444u; + pm.reserveKey = "test:reconcile:malformed"; + pm.itemKey.clear(); + pm.depKey.clear(); + pend.Register(pm); + + uint64 const before = readMoney(); + AhReconcileOnReconnect(); + + PendingMutation held; + if (!pend.Peek(malformedUuid, held) || + held.state != uint8(PMUT_TOMBSTONE)) + { + printf("ahreconcile FAIL: malformed committed facts not held " + "in-doubt\n"); + pass = false; + } + if (rowState("test:reconcile:malformed") != CST_RESERVED || + readMoney() != before) + { + printf("ahreconcile FAIL: malformed committed facts released " + "gold\n"); + pass = false; + } + pend.Take(malformedUuid, held); + } + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` " + "WHERE `auction_id` IN (993101,993102,993104) " + "OR `idem_key` IN ('resolve:%llu','resolve:%llu','resolve:%llu')", + static_cast(commitUuid), + static_cast(malformedUuid), + static_cast(validUuid)); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` " + "WHERE `auction_id` IN (993101,993102,993104) " + "OR `uuid` IN (%llu,%llu,%llu)", + static_cast(commitUuid), + static_cast(malformedUuid), + static_cast(validUuid)); + CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + + if (pass) + { + printf("ahreconcile OK\n"); + return 0; + } + return 2; +} + +/// Cancel-abort reconnect regression in its own process so the one-shot commit +/// failpoint is independent of RunAhReconcileTest's release failpoint. +static int RunAhReconcileAbortTest() +{ + bool pass = true; + CharacterDatabase.AllowAsyncTransactions(); + + uint32 const auctionId = 993103u; + uint64 const uuid = 0xC103ull; + std::string const cutKey = "test:reconcile:abort"; + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u " + "OR `idem_key`='resolve:%llu'", + auctionId, static_cast(uuid)); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` WHERE `auction_id`=%u OR `uuid`=%llu", + auctionId, static_cast(uuid)); + CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + CharacterDatabase.DirectExecute( + "INSERT INTO `characters` (`guid`,`account`,`name`,`money`) " + "VALUES (1,1,'AhAbortTest',100000)"); + + PlayerMutationResult prepared; + prepared.uuid = uuid; + prepared.op = uint8(IPC_PLAYER_CANCEL & 0xFFu); + prepared.status = uint8(MUT_PREPARED); + prepared.reason = 0u; + prepared.facts = MutationFacts(); + prepared.facts.auctionId = auctionId; + prepared.facts.houseId = 7u; + prepared.facts.sellerGuid = 1u; + prepared.facts.curBid = 1000u; + + ByteBuffer bb; + prepared.Encode(bb); + std::string const factsHex = TestHexEncode(bb); + + CharacterDatabase.BeginTransaction(); + CharacterDatabase.PExecute( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('%s',0,2,0,1,0,55,0,%u,0,0)", cutKey.c_str(), auctionId); + CharacterDatabase.PExecute( + "INSERT INTO `ah_worker_journal` " + "(`uuid`,`auction_id`,`kind`,`state`,`facts`," + "`created_time`,`resolved_time`) " + "VALUES (%llu,%u,%u,4,'%s',0,0)", + static_cast(uuid), auctionId, + uint32(IPC_PLAYER_CANCEL & 0xFFu), factsHex.c_str()); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahreconcileabort FAIL: seed commit\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = uuid; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_CANCEL); + pm.auctionId = auctionId; + pm.state = uint8(PMUT_AWAIT_CONFIRM); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 55u; + pm.reserveKey = cutKey; + pm.itemKey.clear(); + pm.depKey.clear(); + MutationPendingMap& pend = sWorld.GetMutationPending(); + pend.Register(pm); + + auto readMoney = []() -> uint64 + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT `money` FROM `characters` WHERE `guid`=1")); + return res ? res->Fetch()[0].GetUInt64() : 0u; + }; + auto rowState = [&cutKey]() -> uint32 + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT `state` FROM `custody_ledger` WHERE `idem_key`='%s'", + cutKey.c_str())); + return res ? res->Fetch()[0].GetUInt32() : 255u; + }; + + uint64 const before = readMoney(); + std::string originalConfig; + std::string testConfig; + if (!TestArmCustodyCommitFailure( + "reconcile-abort-release", originalConfig, testConfig)) + { + TestRestoreConfig(originalConfig, testConfig); + printf("ahreconcileabort FAIL: could not arm commit failure\n"); + return 2; + } + AhReconcileOnReconnect(); + if (!TestRestoreConfig(originalConfig, testConfig)) + { + printf("ahreconcileabort FAIL: could not restore configuration\n"); + return 2; + } + + PendingMutation held; + if (!pend.Peek(uuid, held) || pend.Size() != 1u || + rowState() != CST_RESERVED || readMoney() != before) + { + printf("ahreconcileabort FAIL: failed cut release consumed " + "disposition\n"); + pass = false; + } + + AhProcessReconnectRetryQueue(uint32(time(NULL)) + 6u); + if (pend.Peek(uuid, held) || pend.Size() != 0u || + rowState() != CST_TERMINAL_BACK || readMoney() != before + 55u) + { + printf("ahreconcileabort FAIL: retained cut release did not apply " + "once on retry\n"); + pass = false; + } + + pend.Take(uuid, held); + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u " + "OR `idem_key`='resolve:%llu'", + auctionId, static_cast(uuid)); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` WHERE `auction_id`=%u OR `uuid`=%llu", + auctionId, static_cast(uuid)); + CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + + if (pass) + { + printf("ahreconcileabort OK\n"); + return 0; + } + return 2; +} + /// SP-2 Task 13 self-test for the bot-sell materialization leg. Drives /// AuctionIntentExecutor::TestMaterializeSell directly -- the live path reaches /// it through Apply() -> ApplySell(), but that re-validation chain needs a fully @@ -4790,7 +5644,85 @@ static int RunAhMaterializeTest() } } - // ---- Part 3: orphan sweep reaps strays but spares delivered items ---- + // ---- Part 3: failed sweep commit preserves DB and cache ownership ---- + { + CharacterDatabase.DirectPExecute( + "UPDATE `custody_ledger` SET `created_time`=0 " + "WHERE `idem_key`='%s'", + key.c_str()); + Item* const cachedBefore = sAuctionMgr.GetAItem(itemGuid); + if (!cachedBefore) + { + printf("ahmaterialize FAIL: materialized item missing from " + "cache before sweep\n"); + return 2; + } + CustodyRow custodyBefore; + if (!CustodyLedger::Get(key, custodyBefore)) + { + printf("ahmaterialize FAIL: materialization custody missing " + "before sweep\n"); + return 2; + } + std::unique_ptr candidates(CharacterDatabase.Query( + "SELECT COUNT(*) FROM `custody_ledger` " + "WHERE `idem_key` LIKE 'botlist:%' AND `created_time` < 1 " + "AND `auction_id` NOT IN (SELECT `id` FROM `auction`)")); + if (!candidates || candidates->Fetch()[0].GetUInt64() != 1u) + { + printf("ahmaterialize FAIL: orphan-sweep failure fixture is " + "not isolated\n"); + return 2; + } + + std::string originalConfig; + std::string testConfig; + if (!TestArmCustodyCommitFailure( + "orphan-sweep", originalConfig, testConfig)) + { + TestRestoreConfig(originalConfig, testConfig); + printf("ahmaterialize FAIL: could not arm orphan-sweep failure\n"); + return 2; + } + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + if (!TestRestoreConfig(originalConfig, testConfig)) + { + printf("ahmaterialize FAIL: could not restore configuration\n"); + return 2; + } + + std::unique_ptr itemRow(CharacterDatabase.PQuery( + "SELECT `owner_guid` FROM `item_instance` WHERE `guid`=%u", + itemGuid)); + CustodyRow custodyRow; + if (!itemRow || !CustodyLedger::Get(key, custodyRow) || + itemRow->Fetch()[0].GetUInt32() != botGuid || + custodyRow.state != custodyBefore.state || + custodyRow.ownerGuid != custodyBefore.ownerGuid || + custodyRow.itemGuid != custodyBefore.itemGuid || + custodyRow.auctionId != custodyBefore.auctionId || + sAuctionMgr.GetAItem(itemGuid) != cachedBefore || + cachedBefore->GetOwnerGuid().GetCounter() != botGuid) + { + printf("ahmaterialize FAIL: failed sweep commit changed " + "durable/cache ownership\n"); + pass = false; + } + + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + std::unique_ptr retriedItem(CharacterDatabase.PQuery( + "SELECT 1 FROM `item_instance` WHERE `guid`=%u", itemGuid)); + if (CustodyLedger::Get(key, custodyRow) || retriedItem || + sAuctionMgr.GetAItem(itemGuid)) + { + printf("ahmaterialize FAIL: retained sweep did not apply on " + "retry\n"); + pass = false; + } + } + + // ---- Part 4: successful sweep reaps strays but spares delivered + // items ---- { // Seed synthetic item_instance + botlist rows AFTER SetHighestGuids so // they never influence the generators. Both are "old" (past the 300s @@ -4847,7 +5779,7 @@ static int RunAhMaterializeTest() } } - // ---- Part 4: an outage backlog drains across bounded invocations ---- + // ---- Part 5: an outage backlog drains across bounded invocations ---- { CharacterDatabase.BeginTransaction(); for (uint32 i = 1u; i <= 101u; ++i) @@ -4895,7 +5827,7 @@ static int RunAhMaterializeTest() } } - // Clean up (Part-1 minted item survives the sweep; drop it + fixtures). + // Clean up the minted item and synthetic fixtures. CharacterDatabase.DirectPExecute( "DELETE FROM `custody_ledger` WHERE `idem_key` IN " "('%s','botlist:test:orphan','botlist:test:sold')", key.c_str()); @@ -4995,6 +5927,16 @@ int RunMangosdTest(std::string const& name) return RunAhCustodyRouteTest(); } + if (name == "ahreconcile") + { + return RunAhReconcileTest(); + } + + if (name == "ahreconcileabort") + { + return RunAhReconcileAbortTest(); + } + if (name == "ahmaterialize") { return RunAhMaterializeTest(); diff --git a/src/mangosd/mangosd.conf.dist.in b/src/mangosd/mangosd.conf.dist.in index b3e62b6d1..f3aba5df4 100644 --- a/src/mangosd/mangosd.conf.dist.in +++ b/src/mangosd/mangosd.conf.dist.in @@ -1911,9 +1911,10 @@ SOAP.Port = 7878 # Default: "" (off) # # AH.Service.CustodyFailCommitAt -# TEST ONLY. Force a finalize checked-commit to roll back and report -# failure ONCE (one-shot per process) to exercise the redrive path: -# empty = off, finalize-fail. +# TEST ONLY. Force an AH custody checked-commit to roll back and report +# failure ONCE (one-shot per process) to exercise its retention/redrive +# path: empty = off, finalize-fail, reconcile-release, +# reconcile-abort-release, orphan-sweep. # NEVER set on a live realm. # Default: "" (off) # From 0c00c48703527cc67c4865f01c8e53585b399a59 Mon Sep 17 00:00:00 2001 From: MadMax Date: Mon, 7 Sep 2026 20:15:33 +0100 Subject: [PATCH 13/18] fix(ah): preserve bot escrow during conservation recovery Validate bot materialization markers separately from player seller escrow before terminal value effects. Keep missing and mismatched items held for retry, and cover bot expiry, bid-won expiry and buyout delivery. Use auction-scoped bid custody reads instead of scanning the full reserved ledger, retaining uniqueness and database-error guards. Integrates the conservation checkpoint with the published bounded maintenance series. --- src/game/AuctionHouseBot/CustodyLedger.cpp | 15 +- src/game/AuctionHouseBot/CustodyLedger.h | 6 +- .../WorldHandlers/AuctionHouseHandler.cpp | 146 +++++++--------- src/mangosd/MangosdTest.cpp | 158 ++++++++++++++++++ 4 files changed, 236 insertions(+), 89 deletions(-) diff --git a/src/game/AuctionHouseBot/CustodyLedger.cpp b/src/game/AuctionHouseBot/CustodyLedger.cpp index f649e519c..33dc3a58c 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.cpp +++ b/src/game/AuctionHouseBot/CustodyLedger.cpp @@ -223,15 +223,20 @@ bool CustodyLedger::Get(std::string const& idemKey, CustodyRow& out) return true; } -bool CustodyLedger::GetSingleLiveBidRow(uint32 auctionId, CustodyRow& out) +bool CustodyLedger::GetSingleLiveBidRow(uint32 auctionId, CustodyRow& out, + std::string const& excludeKey) { - // Fetch every live bid row (kind=GOLD, role=BID, state=RESERVED) so we can - // assert there is EXACTLY ONE before trusting it (spec I1: fail closed on + // Fetch live bid rows (kind=GOLD, role=BID, state=RESERVED), excluding the + // optional in-flight reservation. Require EXACTLY ONE (fail closed on // absent or ambiguous rows). No LIMIT -- we must see a second row if present. + std::string escapedKey = excludeKey; + CharacterDatabase.escape_string(escapedKey); QueryResult* result = CharacterDatabase.PQuery( "SELECT " CUSTODY_SELECT_COLS " FROM `custody_ledger` " - "WHERE `auction_id`=%u AND `kind`=%u AND `role`=%u AND `state`=%u", - auctionId, uint32(CUSTODY_GOLD), uint32(ROLE_BID), uint32(CST_RESERVED)); + "WHERE `auction_id`=%u AND `kind`=%u AND `role`=%u AND `state`=%u " + "AND (%u=0 OR `idem_key`<>'%s')", + auctionId, uint32(CUSTODY_GOLD), uint32(ROLE_BID), uint32(CST_RESERVED), + uint32(!excludeKey.empty()), escapedKey.c_str()); if (!result) { return false; diff --git a/src/game/AuctionHouseBot/CustodyLedger.h b/src/game/AuctionHouseBot/CustodyLedger.h index 7d6322f8c..44b0ea980 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.h +++ b/src/game/AuctionHouseBot/CustodyLedger.h @@ -193,9 +193,11 @@ namespace CustodyLedger * * @param auctionId Auction entry id to probe. * @param out Populated with the single live bid row on success. - * @return true iff exactly one live bid row exists for @p auctionId. + * @param excludeKey Optional in-flight delta reservation to omit. + * @return true iff exactly one non-excluded live bid row exists for @p auctionId. */ - bool GetSingleLiveBidRow(uint32 auctionId, CustodyRow& out); + bool GetSingleLiveBidRow(uint32 auctionId, CustodyRow& out, + std::string const& excludeKey = ""); /** * @brief Allocate the next per-event bid sequence for an auction. diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index 7e4af7e78..156a73df7 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -2152,11 +2152,12 @@ static std::string AhMailSubject(uint32 itemTemplate, int32 itemRand, uint32 res // The output row is a value snapshot and the output item remains cache-owned. static bool AhGetCachedReservedItem(std::string const& key, uint32 auctionId, uint32 ownerGuid, uint32 expectedItemGuid, - CustodyRow& row, Item*& item) + CustodyRow& row, Item*& item, + uint8 expectedRole = ROLE_ITEM) { item = NULL; if (key.empty() || !CustodyLedger::Get(key, row) || - row.kind != CUSTODY_ITEM || row.role != ROLE_ITEM || + row.kind != CUSTODY_ITEM || row.role != expectedRole || row.state != CST_RESERVED || row.ownerGuid != ownerGuid || row.beneficiaryGuid != 0u || row.amount != 0u || row.auctionId != auctionId || @@ -2169,9 +2170,9 @@ static bool AhGetCachedReservedItem(std::string const& key, uint32 auctionId, } item = sAuctionMgr.GetAItem(row.itemGuid); - if (!item) + if (!item || item->GetOwnerGuid().GetCounter() != ownerGuid) { - sLog.outError("[AHMut] auction %u escrow item %u missing from cache; " + sLog.outError("[AHMut] auction %u escrow item %u missing/mismatched in cache; " "holding custody for retry", auctionId, row.itemGuid); return false; @@ -2179,21 +2180,52 @@ static bool AhGetCachedReservedItem(std::string const& key, uint32 auctionId, return true; } -// [SP-2] every live (CST_RESERVED CUSTODY_GOLD ROLE_BID) row for an auction. -// LoadNonTerminal + filter: no new CustodyLedger read API; the non-terminal -// set is TTL-bounded. -static void AhLoadLiveBidRows(uint32 auctionId, std::vector& out) +static bool AhPreflightTerminalItem(MutationFacts const& facts, + bool requireDeposit) { - std::vector rows; - CustodyLedger::LoadNonTerminal(rows); - for (size_t i = 0; i < rows.size(); ++i) + // Bot materializations carry a botlist: marker, not player seller escrow. + // Read the marker count in one query so an error cannot look like absence. + std::unique_ptr markers(CharacterDatabase.PQuery( + "SELECT COUNT(*), COALESCE(MAX(`idem_key`),'') FROM `custody_ledger` " + "WHERE `auction_id`=%u AND `idem_key` LIKE 'botlist:%%'", + facts.auctionId)); + if (!markers) { - if (rows[i].auctionId == auctionId && rows[i].kind == CUSTODY_GOLD && - rows[i].role == ROLE_BID && rows[i].state == CST_RESERVED) + return false; + } + uint64 const markerCount = markers->Fetch()[0].GetUInt64(); + CustodyRow itemRow; + Item* item = NULL; + if (markerCount != 0u) + { + if (markerCount != 1u || + !AhGetCachedReservedItem(markers->Fetch()[1].GetCppString(), + facts.auctionId, facts.sellerGuid, facts.itemGuid, + itemRow, item, ROLE_RESOLUTION)) { - out.push_back(rows[i]); + return false; } } + else + { + if (requireDeposit) + { + CustodyRow deposit; + if (!CustodyLedger::Get("dep:" + std::to_string(facts.auctionId), deposit) || + deposit.kind != CUSTODY_GOLD || deposit.role != ROLE_DEPOSIT || + deposit.state != CST_RESERVED || deposit.ownerGuid != facts.sellerGuid || + deposit.amount != facts.deposit || deposit.auctionId != facts.auctionId) + { + return false; + } + } + if (!AhGetCachedReservedItem("item:" + std::to_string(facts.auctionId), + facts.auctionId, facts.sellerGuid, facts.itemGuid, itemRow, item)) + { + return false; + } + } + return item->GetEntry() == facts.itemTemplate; } // [SP-2] the prior/current bidder's live bid row: EXACTLY ONE live bid row for @@ -2202,27 +2234,8 @@ static void AhLoadLiveBidRows(uint32 auctionId, std::vector& out) static bool AhFindPriorBidRow(uint32 auctionId, uint32 bidderGuid, uint32 amount, std::string const& excludeKey, CustodyRow& out) { - std::vector rows; - AhLoadLiveBidRows(auctionId, rows); - bool found = false; - for (size_t i = 0; i < rows.size(); ++i) - { - if (!excludeKey.empty() && rows[i].idemKey == excludeKey) - { - continue; - } - if (found) - { - return false; // ambiguous -> fail closed - } - out = rows[i]; - found = true; - } - if (!found) - { - return false; - } - return out.ownerGuid == bidderGuid && out.amount == amount; + return CustodyLedger::GetSingleLiveBidRow(auctionId, out, excludeKey) && + out.ownerGuid == bidderGuid && out.amount == amount; } // [SP-2] in-txn wallet re-credit WITHOUT a ledger-row flip (the buyout @@ -2693,26 +2706,11 @@ static bool AhFinalizeBidOk(PlayerMutationResult const& res, PendingMutation con } uint32 const remainder = pm.reservedAmount - needed; - if (isBuyoutWin) + if (isBuyoutWin && !AhPreflightTerminalItem(f, true)) { - std::string const depKey = "dep:" + std::to_string(f.auctionId); - CustodyRow depRow; - CustodyRow itemRow; - Item* item = NULL; - if (!CustodyLedger::Get(depKey, depRow) || - depRow.kind != CUSTODY_GOLD || - depRow.role != ROLE_DEPOSIT || depRow.state != CST_RESERVED || - depRow.ownerGuid != f.sellerGuid || depRow.amount != f.deposit || - depRow.auctionId != f.auctionId || - !AhGetCachedReservedItem("item:" + std::to_string(f.auctionId), - f.auctionId, f.sellerGuid, f.itemGuid, - itemRow, item)) - { - sLog.outError("[AHMut] buyout terminal custody unavailable for " - "auction %u; queued for retry", - f.auctionId); - return false; - } + sLog.outError("[AHMut] buyout terminal custody unavailable for " + "auction %u; queued for retry", f.auctionId); + return false; } CustodyRow liveRow; @@ -2816,17 +2814,7 @@ static bool AhFinalizeCancelOk(PlayerMutationResult const& res, PendingMutation { MutationFacts const& f = res.facts; - std::string const depKey = "dep:" + std::to_string(f.auctionId); - CustodyRow depRow; - CustodyRow itemRow; - Item* item = NULL; - if (!CustodyLedger::Get(depKey, depRow) || depRow.kind != CUSTODY_GOLD || - depRow.role != ROLE_DEPOSIT || depRow.state != CST_RESERVED || - depRow.ownerGuid != f.sellerGuid || depRow.amount != f.deposit || - depRow.auctionId != f.auctionId || - !AhGetCachedReservedItem("item:" + std::to_string(f.auctionId), - f.auctionId, f.sellerGuid, f.itemGuid, - itemRow, item)) + if (!AhPreflightTerminalItem(f, true)) { sLog.outError("[AHMut] cancel terminal custody unavailable for auction " "%u; queued for retry", @@ -3290,8 +3278,8 @@ void AhProcessRedriveQueue(uint32 nowSec) // reservation for an auction. The cancel-CONFIRM path salts the cut idem key // with the CANCEL uuid ("cut::"), but a RESOLVE_CANCELLED_UNLOCK // arrives with its OWN (worker-minted) uuid, so the cut cannot be found by a -// deterministic point key -- it is located by auction + role instead (mirrors -// AhLoadLiveBidRows). Returns the row's real idemKey for the release. +// deterministic point key -- it is located by auction + role instead. +// Returns the row's real idemKey for the release. static bool AhFindLiveCutRow(uint32 auctionId, CustodyRow& out) { std::vector rows; @@ -3330,19 +3318,11 @@ uint8 AhHandleResolveApply(ResolveApply const& ra) (ra.kind == uint8(RESOLVE_WON) || ra.kind == uint8(RESOLVE_EXPIRED_NOBID) || (ra.kind == uint8(RESOLVE_REPAIR_RETURN) && !repairRefundOnly)); - if (needsItem) + if (needsItem && !AhPreflightTerminalItem(f, false)) { - CustodyRow itemRow; - Item* item = NULL; - if (!AhGetCachedReservedItem("item:" + std::to_string(f.auctionId), - f.auctionId, f.sellerGuid, f.itemGuid, - itemRow, item)) - { - sLog.outError("[AHMut] resolve kind %u auction %u missing terminal " - "item; RES_FAILED", - uint32(ra.kind), f.auctionId); - return uint8(RES_FAILED); - } + sLog.outError("[AHMut] resolve kind %u auction %u missing terminal " + "item; RES_FAILED", uint32(ra.kind), f.auctionId); + return uint8(RES_FAILED); } CustodyDeferred def; @@ -3383,9 +3363,11 @@ uint8 AhHandleResolveApply(ResolveApply const& ra) } else { - std::vector liveRows; - AhLoadLiveBidRows(f.auctionId, liveRows); - bidRowOk = liveRows.empty(); + std::unique_ptr liveRows(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `custody_ledger` WHERE `auction_id`=%u " + "AND `kind`=%u AND `role`=%u AND `state`=%u", + f.auctionId, uint32(CUSTODY_GOLD), uint32(ROLE_BID), uint32(CST_RESERVED))); + bidRowOk = liveRows && liveRows->Fetch()[0].GetUInt64() == 0u; } if (!bidRowOk) { diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index a72a3d671..112b074e5 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -5432,6 +5432,159 @@ static int RunAhReconcileAbortTest() return 2; } +/// Marker-owned worker resolutions and player buyouts retain missing or +/// mismatched item custody, then complete exactly once after it is restored. +static int RunAhBotTerminalTest() +{ + bool pass = true; + uint32 const buyer = 990117u; + uint32 const bot = AHBOT_SYSTEM_OWNER_GUID; + CharacterDatabase.AllowAsyncTransactions(); + sObjectMgr.SetHighestGuids(); + sObjectMgr.LoadItemPrototypes(); + if (!CharacterDatabase.DirectPExecute( + "REPLACE INTO `characters` (`guid`,`account`,`name`,`money`) " + "VALUES (%u,1,'AhBotTerm',100000)", buyer)) + { + printf("ahbotterminal FAIL: seed receiver\n"); + return 2; + } + + // Bot expiry, bid-won expiry, and player buyout all use marker custody, + // not a player's item:/dep: pair. A failed preflight must remain retryable. + for (uint32 mode = 0; mode < 3u; ++mode) + { + uint32 const auctionId = 991117u + mode; + uint64 const uuid = 0xBB117ull + mode; + std::string const markerKey = "botlist:test:terminal:" + std::to_string(mode); + std::string const bidKey = "bid:" + std::to_string(auctionId) + ":test"; + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + Item* const item = TestCreateCachedAuctionItem(19019u, bot); + if (!item) + { + printf("ahbotterminal FAIL: create escrow item\n"); + return 2; + } + uint32 const itemGuid = item->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); + CustodyLedger::Insert(TestCustodyRow(0, markerKey, CUSTODY_ITEM, + ROLE_RESOLUTION, bot, 0, itemGuid, auctionId)); + if (mode != 0u) + { + CustodyLedger::Insert(TestCustodyRow(0, bidKey, CUSTODY_GOLD, + ROLE_BID, buyer, 800u, 0, auctionId)); + } + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahbotterminal FAIL: seed custody\n"); + return 2; + } + + MutationFacts facts = {}; + facts.auctionId = auctionId; + facts.houseId = 7; + facts.sellerGuid = bot; + facts.itemGuid = itemGuid; + facts.itemTemplate = 19019u; + facts.itemCount = 1; + facts.buyout = 800u; + if (mode != 0u) + { + facts.curBidderGuid = buyer; + facts.curBid = facts.effectiveBid = 800u; + } + PlayerMutationResult result = {}; + result.uuid = uuid; + result.op = uint8(IPC_PLAYER_BUYOUT & 0xFFu); + result.status = uint8(MUT_OK); + result.facts = facts; + ResolveApply resolve = {}; + resolve.uuid = uuid; + resolve.kind = mode == 0u ? uint8(RESOLVE_EXPIRED_NOBID) : uint8(RESOLVE_WON); + resolve.facts = facts; + PendingMutation pending = {}; + if (mode == 2u) + { + pending.uuid = uuid; + pending.op = IPC_PLAYER_BUYOUT; + pending.playerGuidLow = buyer; + pending.auctionId = auctionId; + pending.state = PMUT_AWAIT_RESULT; + pending.sentSec = uint32(time(NULL)); + pending.reserveKey = bidKey; + pending.reservedAmount = 800u; + sWorld.GetMutationPending().Register(pending); + } + auto apply = [&](uint32 attempt) + { + if (mode != 2u) + { + AhHandleResolveApply(resolve); + } + else if (attempt == 0u) + { + AhHandlePlayerMutationResult(result); + } + else + { + AhProcessRedriveQueue(uint32(time(NULL)) + attempt * 10u); + } + }; + auto mailCount = [&]() -> uint64 + { + std::unique_ptr rows(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `mail_items` WHERE `item_guid`=%u " + "AND `receiver`=%u", itemGuid, buyer)); + return rows ? rows->Fetch()[0].GetUInt64() : 0u; + }; + + sAuctionMgr.RemoveAItem(itemGuid); + apply(0u); + sAuctionMgr.AddAItem(item); + CharacterDatabase.DirectPExecute( + "UPDATE `custody_ledger` SET `item_guid`=%u WHERE `idem_key`='%s'", + itemGuid + 1u, markerKey.c_str()); + apply(1u); + CustodyRow row; + if (mailCount() != 0u || CustodyService::ResolutionApplied(uuid) || + (mode != 0u && (!CustodyLedger::Get(bidKey, row) || row.state != CST_RESERVED))) + { + printf("ahbotterminal FAIL: mode %u missing/mismatched custody moved value\n", mode); + pass = false; + } + CharacterDatabase.DirectPExecute( + "UPDATE `custody_ledger` SET `item_guid`=%u WHERE `idem_key`='%s'", + itemGuid, markerKey.c_str()); + apply(2u); + apply(3u); + std::unique_ptr persisted(CharacterDatabase.PQuery( + "SELECT `owner_guid` FROM `item_instance` WHERE `guid`=%u", itemGuid)); + if (sAuctionMgr.GetAItem(itemGuid) || + !CustodyLedger::Get(markerKey, row) || row.state != CST_RESERVED || + (mode == 0u && persisted) || + (mode != 0u && (!persisted || persisted->Fetch()[0].GetUInt32() != buyer || + mailCount() != 1u || !CustodyLedger::Get(bidKey, row) || row.state != CST_TERMINAL_OK)) || + (mode != 2u && !CustodyService::ResolutionApplied(uuid))) + { + printf("ahbotterminal FAIL: mode %u did not finish exactly once after retry\n", mode); + pass = false; + } + if (Item* leftover = sAuctionMgr.GetAItem(itemGuid)) + { + sAuctionMgr.RemoveAItem(itemGuid); + delete leftover; + } + CharacterDatabase.DirectPExecute("DELETE FROM `item_instance` WHERE `guid`=%u", itemGuid); + CharacterDatabase.DirectPExecute("DELETE FROM `mail_items` WHERE `item_guid`=%u", itemGuid); + CharacterDatabase.DirectPExecute("DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + } + CharacterDatabase.DirectPExecute("DELETE FROM `mail` WHERE `receiver`=%u", buyer); + CharacterDatabase.DirectPExecute("DELETE FROM `characters` WHERE `guid`=%u", buyer); + printf("ahbotterminal %s\n", pass ? "OK" : "FAIL"); + return pass ? 0 : 1; +} + /// SP-2 Task 13 self-test for the bot-sell materialization leg. Drives /// AuctionIntentExecutor::TestMaterializeSell directly -- the live path reaches /// it through Apply() -> ApplySell(), but that re-validation chain needs a fully @@ -5942,6 +6095,11 @@ int RunMangosdTest(std::string const& name) return RunAhMaterializeTest(); } + if (name == "ahbotterminal") + { + return RunAhBotTerminalTest(); + } + printf("%s FAIL: unknown test\n", name.c_str()); return 2; } From 62b5b589cbaed8b78eab8ba5ca7c7fc637ad8142 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 8 Sep 2026 13:55:47 +0100 Subject: [PATCH 14/18] fix(ah): protect pending sales during orphan cleanup --- .../AuctionHouseBot/AuctionIntentExecutor.cpp | 24 ++++-- src/mangosd/MangosdTest.cpp | 74 +++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp index c7eafc002..b7319b71f 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp @@ -745,14 +745,17 @@ AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, // Candidates: durable botlist rows, past the grace window, whose auction id // is absent from the shared `auction` table (worker never wrote / already - // removed the book row). + // removed the book row). Other reserved custody means value finalization + // may still need the marker and item, even after the book row was removed. uint64 const queryLimit = uint64(maxRows) + 1u; QueryResult* q = CharacterDatabase.PQuery( - "SELECT `id`, `item_guid`, `owner_guid` " - "FROM `custody_ledger` " - "WHERE `idem_key` LIKE 'botlist:%%' AND `created_time` < " UI64FMTD " " - "AND `auction_id` NOT IN (SELECT `id` FROM `auction`) " - "ORDER BY `id` LIMIT " UI64FMTD, + "SELECT c.`id`, c.`item_guid`, c.`owner_guid` " + "FROM `custody_ledger` c " + "WHERE c.`idem_key` LIKE 'botlist:%%' AND c.`created_time` < " UI64FMTD " " + "AND c.`auction_id` NOT IN (SELECT `id` FROM `auction`) " + "AND NOT EXISTS (SELECT 1 FROM `custody_ledger` r " + "WHERE r.`auction_id`=c.`auction_id` AND r.`state`=0 AND r.`id`<>c.`id`) " + "ORDER BY c.`id` LIMIT " UI64FMTD, cutoff, queryLimit); if (q == NULL) { @@ -843,12 +846,17 @@ AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, // Drop in-memory escrow only after both durable deletes commit. This is a // harmless no-op after restart, where the orphan was never reloaded. + // A buyer may have relisted the same item since this marker was created; + // preserve that escrow just as the durable owner guard preserves its row. for (std::vector::const_iterator it = candidates.begin(); it != candidates.end(); ++it) { Item* const orphan = sAuctionMgr.GetAItem(it->itemGuid); - sAuctionMgr.RemoveAItem(it->itemGuid); - delete orphan; + if (orphan && orphan->GetOwnerGuid().GetCounter() == it->ownerGuid) + { + sAuctionMgr.RemoveAItem(it->itemGuid); + delete orphan; + } } report.swept = report.selected; sLog.outString("[AHExecutor] orphan materialization sweep:" diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 112b074e5..8ff903888 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -5541,6 +5541,21 @@ static int RunAhBotTerminalTest() sAuctionMgr.RemoveAItem(itemGuid); apply(0u); + if (mode != 0u) + { + OrphanMaterializationSweepReport const sweep = + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + CustodyRow heldMarker; + std::unique_ptr heldItem(CharacterDatabase.PQuery( + "SELECT 1 FROM `item_instance` WHERE `guid`=%u", itemGuid)); + if (!sweep.committed || sweep.selected != 0u || !heldItem || + !CustodyLedger::Get(markerKey, heldMarker) || + heldMarker.state != CST_RESERVED) + { + printf("ahbotterminal FAIL: mode %u sweep destroyed held sale escrow\n", mode); + pass = false; + } + } sAuctionMgr.AddAItem(item); CharacterDatabase.DirectPExecute( "UPDATE `custody_ledger` SET `item_guid`=%u WHERE `idem_key`='%s'", @@ -5570,6 +5585,20 @@ static int RunAhBotTerminalTest() printf("ahbotterminal FAIL: mode %u did not finish exactly once after retry\n", mode); pass = false; } + if (mode != 0u) + { + OrphanMaterializationSweepReport const sweep = + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + std::unique_ptr deliveredItem(CharacterDatabase.PQuery( + "SELECT `owner_guid` FROM `item_instance` WHERE `guid`=%u", itemGuid)); + if (!sweep.committed || sweep.swept != 1u || + CustodyLedger::Get(markerKey, row) || !deliveredItem || + deliveredItem->Fetch()[0].GetUInt32() != buyer || mailCount() != 1u) + { + printf("ahbotterminal FAIL: mode %u terminal sweep changed delivered item\n", mode); + pass = false; + } + } if (Item* leftover = sAuctionMgr.GetAItem(itemGuid)) { sAuctionMgr.RemoveAItem(itemGuid); @@ -5980,6 +6009,51 @@ static int RunAhMaterializeTest() } } + // ---- Part 6: an old bot marker must not evict a buyer's relisted item ---- + { + Item* const relisted = TestCreateCachedAuctionItem(itemId, buyerGuid); + if (!relisted) + { + printf("ahmaterialize FAIL: relisted item fixture\n"); + return 2; + } + uint32 const relistedGuid = relisted->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); + CustodyLedger::Insert(TestCustodyRow(0, "botlist:test:relisted", + CUSTODY_ITEM, ROLE_RESOLUTION, botGuid, 0, relistedGuid, 99900003u)); + CharacterDatabase.PExecute( + "INSERT INTO `auction` (`id`,`itemguid`,`itemowner`) " + "VALUES (99900004,%u,%u)", relistedGuid, buyerGuid); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahmaterialize FAIL: relisted marker seed commit\n"); + return 2; + } + OrphanMaterializationSweepReport const sweep = + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + CustodyRow marker; + std::unique_ptr persisted(CharacterDatabase.PQuery( + "SELECT `owner_guid` FROM `item_instance` WHERE `guid`=%u", relistedGuid)); + if (!sweep.committed || sweep.swept != 1u || + CustodyLedger::Get("botlist:test:relisted", marker) || + sAuctionMgr.GetAItem(relistedGuid) != relisted || !persisted || + persisted->Fetch()[0].GetUInt32() != buyerGuid) + { + printf("ahmaterialize FAIL: old marker sweep destroyed buyer's relisted escrow\n"); + pass = false; + } + if (Item* leftover = sAuctionMgr.GetAItem(relistedGuid)) + { + sAuctionMgr.RemoveAItem(relistedGuid); + delete leftover; + } + CharacterDatabase.DirectExecute("DELETE FROM `auction` WHERE `id`=99900004"); + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='botlist:test:relisted'"); + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", relistedGuid); + } + // Clean up the minted item and synthetic fixtures. CharacterDatabase.DirectPExecute( "DELETE FROM `custody_ledger` WHERE `idem_key` IN " From 060be9738624ea9073461d5d3be8a7e8a3c8d3cf Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 8 Sep 2026 16:48:54 +0100 Subject: [PATCH 15/18] fix(ah): address recovery review and build findings --- .../AuctionHouseBot/AuctionIntentExecutor.cpp | 14 +- src/game/AuctionHouseBot/CustodyLedger.cpp | 8 +- src/game/AuctionHouseBot/CustodyLedger.h | 1 + .../AuctionHouseBot/CustodyReconciler.cpp | 26 +++- src/game/AuctionHouseBot/CustodyReconciler.h | 16 ++ .../WorldHandlers/AuctionHouseHandler.cpp | 14 +- src/mangosd/MangosdTest.cpp | 147 +++++++++++++++++- src/mangosd/Master.cpp | 2 +- 8 files changed, 213 insertions(+), 15 deletions(-) diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp index b7319b71f..2f0705a02 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp @@ -747,18 +747,22 @@ AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, // is absent from the shared `auction` table (worker never wrote / already // removed the book row). Other reserved custody means value finalization // may still need the marker and item, even after the book row was removed. + // The sentinel keeps an empty result distinct from a failed query (NULL). uint64 const queryLimit = uint64(maxRows) + 1u; QueryResult* q = CharacterDatabase.PQuery( - "SELECT c.`id`, c.`item_guid`, c.`owner_guid` " + "(SELECT c.`id`, c.`item_guid`, c.`owner_guid` " "FROM `custody_ledger` c " "WHERE c.`idem_key` LIKE 'botlist:%%' AND c.`created_time` < " UI64FMTD " " "AND c.`auction_id` NOT IN (SELECT `id` FROM `auction`) " "AND NOT EXISTS (SELECT 1 FROM `custody_ledger` r " "WHERE r.`auction_id`=c.`auction_id` AND r.`state`=0 AND r.`id`<>c.`id`) " - "ORDER BY c.`id` LIMIT " UI64FMTD, + "ORDER BY c.`id` LIMIT " UI64FMTD ") UNION ALL SELECT 0,0,0", cutoff, queryLimit); if (q == NULL) { + report.committed = false; + sLog.outError("[AHExecutor] orphan materialization candidate query " + "failed; sweep will retry"); return report; } @@ -772,13 +776,17 @@ AuctionIntentExecutor::SweepOrphanMaterializations(uint32 nowSec, candidates.reserve(maxRows); do { + Field* f = q->Fetch(); + if (f[0].GetUInt32() == 0u) + { + continue; + } if (candidates.size() == maxRows) { report.morePending = true; break; } - Field* f = q->Fetch(); Candidate candidate; candidate.ledgerId = f[0].GetUInt32(); candidate.itemGuid = f[1].GetUInt32(); diff --git a/src/game/AuctionHouseBot/CustodyLedger.cpp b/src/game/AuctionHouseBot/CustodyLedger.cpp index 33dc3a58c..fec206391 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.cpp +++ b/src/game/AuctionHouseBot/CustodyLedger.cpp @@ -175,13 +175,15 @@ void CustodyLedger::LoadReconcileSnapshot(std::vector& out bool CustodyLedger::AuctionExists(uint32 auctionId) { QueryResult* result = CharacterDatabase.PQuery( - "SELECT 1 FROM `auction` WHERE `id`=%u LIMIT 1", auctionId); + "SELECT COUNT(*) FROM `auction` WHERE `id`=%u", auctionId); if (!result) { - return false; + // Repair must positively establish absence before moving custody. + return true; } + bool const exists = result->Fetch()[0].GetUInt64() != 0u; delete result; - return true; + return exists; } void CustodyLedger::LoadNonTerminal(std::vector& out) diff --git a/src/game/AuctionHouseBot/CustodyLedger.h b/src/game/AuctionHouseBot/CustodyLedger.h index 44b0ea980..49639dc0b 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.h +++ b/src/game/AuctionHouseBot/CustodyLedger.h @@ -131,6 +131,7 @@ namespace CustodyLedger void LoadReconcileSnapshot(std::vector& out); + /// Conservative liveness guard: query failure also returns true. bool AuctionExists(uint32 auctionId); /** diff --git a/src/game/AuctionHouseBot/CustodyReconciler.cpp b/src/game/AuctionHouseBot/CustodyReconciler.cpp index b812eba3a..5f275172a 100644 --- a/src/game/AuctionHouseBot/CustodyReconciler.cpp +++ b/src/game/AuctionHouseBot/CustodyReconciler.cpp @@ -5,6 +5,22 @@ * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 * * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. */ #include "CustodyReconciler.h" @@ -63,7 +79,7 @@ namespace CustodyRepairOwnership ownership, CustodyFindingState state) { - CustodyFinding finding; + CustodyFinding finding = {}; finding.row = row; finding.reason = reason; finding.repairOwnership = ownership; @@ -408,7 +424,7 @@ void CustodyReconciler::Scan(std::vector const& groups, { if (requiresBid) { - CustodyFinding finding; + CustodyFinding finding = {}; finding.row = ExpectedRow("bid:" + std::to_string(group.auctionId) + ":missing", CUSTODY_GOLD, ROLE_BID, group.auction.bidderGuid, group.auction.bid, 0, @@ -421,7 +437,7 @@ void CustodyReconciler::Scan(std::vector const& groups, { for (size_t i = 0; i < bidRows.size(); ++i) { - CustodyFinding finding; + CustodyFinding finding = {}; finding.row = *bidRows[i]; finding.reason = CUSTODY_FINDING_UNEXPECTED; bidFindings.push_back(finding); @@ -431,7 +447,7 @@ void CustodyReconciler::Scan(std::vector const& groups, { for (size_t i = 0; i < bidRows.size(); ++i) { - CustodyFinding finding; + CustodyFinding finding = {}; finding.row = *bidRows[i]; finding.reason = CUSTODY_FINDING_DUPLICATE; bidFindings.push_back(finding); @@ -441,7 +457,7 @@ void CustodyReconciler::Scan(std::vector const& groups, group.auction.bidderGuid, group.auction.bid, 0, group.auctionId)) { - CustodyFinding finding; + CustodyFinding finding = {}; finding.row = *bidRows[0]; finding.reason = CUSTODY_FINDING_MISMATCHED; bidFindings.push_back(finding); diff --git a/src/game/AuctionHouseBot/CustodyReconciler.h b/src/game/AuctionHouseBot/CustodyReconciler.h index 1d05cd954..ab487f6bc 100644 --- a/src/game/AuctionHouseBot/CustodyReconciler.h +++ b/src/game/AuctionHouseBot/CustodyReconciler.h @@ -5,6 +5,22 @@ * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 * * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. */ #ifndef MANGOS_CUSTODY_RECONCILER_H diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index 156a73df7..eb2ad0b49 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -3741,9 +3741,13 @@ static std::map s_ahReconnectRetries; static AhReconnectDisposition AhResolveForwardFromJournal( PendingMutation const& pm, AhJournalPeek const& jp) { + // Cancel confirmation only updates the journal state: its stored envelope + // still describes PREPARED. APPLIED is ambiguous (it also records ABORT). + bool const committedCancel = jp.payloadOk && jp.state == 1u && + pm.op == IPC_PLAYER_CANCEL && jp.result.status == uint8(MUT_PREPARED); if (!jp.payloadOk || jp.auctionId != pm.auctionId || jp.result.op != uint8(pm.op & 0xFFu) || - jp.result.status != uint8(MUT_OK)) + (jp.result.status != uint8(MUT_OK) && !committedCancel)) { sLog.outError("[AHMut] reconcile uuid " UI64FMTD ": committed journal " "payload invalid; holding reservation in-doubt", @@ -3751,8 +3755,14 @@ static AhReconnectDisposition AhResolveForwardFromJournal( sWorld.GetMutationPending().Tombstone(pm.uuid); return AH_RECONNECT_HELD; } + PlayerMutationResult result = jp.result; + if (committedCancel) + { + result.op = uint8(IPC_PLAYER_CANCEL_CONFIRM & 0xFFu); + result.status = uint8(MUT_OK); + } // Takes the pending and applies the fail-closed finalize. - AhHandlePlayerMutationResult(jp.result); + AhHandlePlayerMutationResult(result); return AH_RECONNECT_COMPLETE; } diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 8ff903888..f4df54227 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -1569,11 +1569,26 @@ static int RunCustodyTest() livenessAuctionId, now + HOUR); bool const blockedAfterInsert = found && !AhRepairFindingMutationAllowed(scanned); + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `auction` TO `auction_test_unavailable`")) + { + printf("custody FAIL: could not inject auction query failure\n"); + return 2; + } + bool const blockedOnQueryFailure = found && + !AhRepairFindingMutationAllowed(scanned); + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `auction_test_unavailable` TO `auction`")) + { + printf("custody FAIL: could not restore auction table\n"); + return 2; + } CharacterDatabase.DirectPExecute( "DELETE FROM `auction` WHERE `id`=%u", livenessAuctionId); bool const allowedAfterDelete = found && AhRepairFindingMutationAllowed(scanned); - if (!found || !blockedAfterInsert || !allowedAfterDelete) + if (!found || !blockedAfterInsert || !blockedOnQueryFailure || + !allowedAfterDelete) { printf("custody FAIL: repair liveness guard ignored post-scan DB state\n"); pass = false; @@ -5270,6 +5285,110 @@ static int RunAhReconcileTest() pend.Take(malformedUuid, held); } + // ---- Part 4: COMMITTED cancels retain their PREPARED journal envelope ---- + { + uint32 const auctionId = 993105u; + uint64 const uuid = 0xC105ull; + sObjectMgr.SetHighestGuids(); + sObjectMgr.LoadItemPrototypes(); + Item* const item = TestCreateCachedAuctionItem(19019u, 1u); + if (!item) + { + printf("ahreconcile FAIL: create committed-cancel item\n"); + return 2; + } + uint32 const itemGuid = item->GetGUIDLow(); + PlayerMutationResult prepared = {}; + prepared.uuid = uuid; + prepared.op = uint8(IPC_PLAYER_CANCEL & 0xFFu); + prepared.status = uint8(MUT_PREPARED); + prepared.facts.auctionId = auctionId; + prepared.facts.houseId = 7u; + prepared.facts.sellerGuid = 1u; + prepared.facts.itemGuid = itemGuid; + prepared.facts.itemTemplate = 19019u; + prepared.facts.itemCount = 1u; + prepared.facts.deposit = 32u; + ByteBuffer bytes; + prepared.Encode(bytes); + std::string const hex = TestHexEncode(bytes); + CharacterDatabase.BeginTransaction(); + CustodyLedger::Insert(TestCustodyRow(0, "dep:993105", CUSTODY_GOLD, + ROLE_DEPOSIT, 1u, 32u, 0, auctionId)); + CustodyLedger::Insert(TestCustodyRow(0, "item:993105", CUSTODY_ITEM, + ROLE_ITEM, 1u, 0, itemGuid, auctionId)); + CharacterDatabase.PExecute( + "INSERT INTO `ah_worker_journal` " + "(`uuid`,`auction_id`,`kind`,`state`,`facts`," + "`created_time`,`resolved_time`) VALUES (%llu,%u,%u,3,'%s',0,0)", + static_cast(uuid), auctionId, + uint32(prepared.op), hex.c_str()); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahreconcile FAIL: seed committed-cancel journal\n"); + return 2; + } + PendingMutation pm = {}; + pm.uuid = uuid; + pm.playerGuidLow = 1u; + pm.op = IPC_PLAYER_CANCEL; + pm.auctionId = auctionId; + pm.state = PMUT_AWAIT_CONFIRM; + pm.itemKey = "item:993105"; + pm.depKey = "dep:993105"; + pend.Register(pm); + auto itemMails = [&]() -> uint64 + { + std::unique_ptr rows(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `mail_items` " + "WHERE `item_guid`=%u AND `receiver`=1", itemGuid)); + return rows ? rows->Fetch()[0].GetUInt64() : 0u; + }; + uint64 const before = readMoney(); + AhReconcileOnReconnect(); + PendingMutation held; + // APPLIED + PREPARED can mean an abort; it is not proof of cancellation. + if (!pend.Peek(uuid, held) || itemMails() != 0u || + rowState("dep:993105") != CST_RESERVED || + rowState("item:993105") != CST_RESERVED || readMoney() != before) + { + printf("ahreconcile FAIL: ambiguous applied cancel moved value\n"); + pass = false; + } + CharacterDatabase.DirectPExecute( + "UPDATE `ah_worker_journal` SET `state`=1 WHERE `uuid`=%llu", + static_cast(uuid)); + AhReconcileOnReconnect(); + AhReconcileOnReconnect(); + if (pend.Peek(uuid, held) || itemMails() != 1u || + rowState("dep:993105") != CST_TERMINAL_OK || + rowState("item:993105") != CST_TERMINAL_OK || + sAuctionMgr.GetAItem(itemGuid) || readMoney() != before) + { + printf("ahreconcile FAIL: committed prepared cancel did not " + "complete exactly once\n"); + pass = false; + } + pend.Take(uuid, held); + if (Item* leftover = sAuctionMgr.GetAItem(itemGuid)) + { + sAuctionMgr.RemoveAItem(itemGuid); + delete leftover; + } + CharacterDatabase.DirectPExecute( + "DELETE FROM `mail` WHERE `id` IN " + "(SELECT `mail_id` FROM `mail_items` WHERE `item_guid`=%u)", itemGuid); + CharacterDatabase.DirectPExecute( + "DELETE FROM `mail_items` WHERE `item_guid`=%u", itemGuid); + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", itemGuid); + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=993105"); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` WHERE `uuid`=%llu", + static_cast(uuid)); + } + CharacterDatabase.DirectPExecute( "DELETE FROM `custody_ledger` " "WHERE `auction_id` IN (993101,993102,993104) " @@ -6054,6 +6173,32 @@ static int RunAhMaterializeTest() "DELETE FROM `item_instance` WHERE `guid`=%u", relistedGuid); } + // ---- Part 7: empty and failed candidate queries have distinct outcomes ---- + { + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `custody_ledger` TO `custody_ledger_test_unavailable`")) + { + printf("ahmaterialize FAIL: could not inject candidate query failure\n"); + return 2; + } + OrphanMaterializationSweepReport const failed = + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `custody_ledger_test_unavailable` TO `custody_ledger`")) + { + printf("ahmaterialize FAIL: could not restore custody table\n"); + return 2; + } + OrphanMaterializationSweepReport const empty = + sAuctionIntentExecutor.SweepOrphanMaterializations(301u, 100u); + if (failed.committed || failed.selected || failed.swept || + !empty.committed || empty.selected || empty.swept || empty.morePending) + { + printf("ahmaterialize FAIL: failed query reported as drained\n"); + pass = false; + } + } + // Clean up the minted item and synthetic fixtures. CharacterDatabase.DirectPExecute( "DELETE FROM `custody_ledger` WHERE `idem_key` IN " diff --git a/src/mangosd/Master.cpp b/src/mangosd/Master.cpp index 0d0579155..3f96237f5 100644 --- a/src/mangosd/Master.cpp +++ b/src/mangosd/Master.cpp @@ -395,7 +395,7 @@ int Master::Run(std::string const& testMode) sLog.outString("mangosd test '%s' exit %d", testMode.c_str(), rc); sLog.Flush(); fflush(stdout); - _exit(rc); + std::_Exit(rc); } ClearOnlineAccounts(); From 06d6262b2ccbfc1a683635b6a06be30190762edc Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 8 Sep 2026 17:33:01 +0100 Subject: [PATCH 16/18] fix(test): require explicit destructive self-test opt-in --- src/mangosd/MangosdTest.h | 3 ++- src/mangosd/Master.cpp | 9 ++++++++- src/mangosd/Master.h | 3 ++- src/mangosd/mangosd.cpp | 18 ++++++++++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/mangosd/MangosdTest.h b/src/mangosd/MangosdTest.h index 58b660294..911ca0a0e 100644 --- a/src/mangosd/MangosdTest.h +++ b/src/mangosd/MangosdTest.h @@ -3,7 +3,8 @@ #include -/// In-process mangosd test harness (`mangosd -t `). Runs AFTER config + +/// Destructive harness (`mangosd --allow-destructive-tests -t `). +/// Use only a disposable database configuration. Runs AFTER config + /// DB init but BEFORE world load. Returns 0 on pass, non-zero on fail. int RunMangosdTest(std::string const& name); diff --git a/src/mangosd/Master.cpp b/src/mangosd/Master.cpp index 3f96237f5..bb1051341 100644 --- a/src/mangosd/Master.cpp +++ b/src/mangosd/Master.cpp @@ -382,8 +382,15 @@ void Master::ShutdownWorld() sMapMgr.UnloadAll(); } -int Master::Run(std::string const& testMode) +int Master::Run(std::string const& testMode, bool allowDestructiveTests) { + if (!testMode.empty() && !allowDestructiveTests) + { + sLog.outError("Self-tests can DELETE character data. Use a disposable " + "database configuration and --allow-destructive-tests " + "to opt in. No databases have been opened."); + return 1; + } if (!StartDatabases()) { return 1; diff --git a/src/mangosd/Master.h b/src/mangosd/Master.h index d1f8da356..321700209 100644 --- a/src/mangosd/Master.h +++ b/src/mangosd/Master.h @@ -64,7 +64,8 @@ class Master * * @return The process exit code. */ - int Run(std::string const& testMode = ""); + int Run(std::string const& testMode = "", + bool allowDestructiveTests = false); private: diff --git a/src/mangosd/mangosd.cpp b/src/mangosd/mangosd.cpp index 414bb13a0..d33d7a745 100644 --- a/src/mangosd/mangosd.cpp +++ b/src/mangosd/mangosd.cpp @@ -161,7 +161,8 @@ static void usage(const char* prog) " -v, --version print version and exist\n\r" " -c use config_file as configuration file\n\r" " -a, --ahbot use config_file as ahbot configuration file\n\r" - " -t run an in-process self-test and exit\n\r" + " -t run a DESTRUCTIVE self-test and exit\n\r" + " --allow-destructive-tests opt in; use ONLY disposable databases\n\r" #ifdef WIN32 " Running as service functions:\n\r" " -s run run as service\n\r" @@ -206,6 +207,7 @@ int main(int argc, char** argv) char serviceDaemonMode = '\0'; std::string testMode; + bool allowDestructiveTests = false; // Walked by hand rather than with ACE_Get_Opt (gone with the rest of ACE) or // getopt (absent on MSVC). Four options do not justify a dependency. @@ -231,6 +233,10 @@ int main(int argc, char** argv) { testMode = argv[++i]; } + else if (arg == "--allow-destructive-tests") + { + allowDestructiveTests = true; + } else if (arg == "-s" && hasValue) { const std::string mode = argv[++i]; @@ -258,6 +264,14 @@ int main(int argc, char** argv) } } + if ((!testMode.empty() && serviceDaemonMode != '\0') || + (allowDestructiveTests && testMode.empty())) + { + sLog.outError("Self-test options require -t and cannot be combined " + "with service or daemon mode."); + return 1; + } + #ifdef _WIN32 // windows service command need execute before config read switch (serviceDaemonMode) { @@ -382,7 +396,7 @@ int main(int argc, char** argv) // runs the world loop on this thread and returns once the world has stopped // and every service has been joined. Master master; - const int runCode = master.Run(testMode); + const int runCode = master.Run(testMode, allowDestructiveTests); ///- Remove signal handling before leaving unhook_signals(); From 5f984f2b7cda009feb42ded651c4d0f7aaf0331d Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 8 Sep 2026 17:47:42 +0100 Subject: [PATCH 17/18] fix(ah): gate legacy lookups and settle generated bids atomically --- .../AuctionHouseBot/AuctionIntentExecutor.cpp | 18 +- src/game/AuctionHouseBot/CustodyLedger.cpp | 29 +++ src/game/AuctionHouseBot/CustodyLedger.h | 3 + src/game/Object/AuctionHouseMgr.cpp | 91 +++++++- src/game/Object/AuctionHouseMgr.h | 3 +- .../WorldHandlers/AuctionHouseHandler.cpp | 12 ++ src/game/WorldHandlers/World.cpp | 1 + src/ipc/AuctionIntents.h | 3 +- src/mangosd/MangosdTest.cpp | 197 ++++++++++++++++++ 9 files changed, 342 insertions(+), 15 deletions(-) diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp index 2f0705a02..d3af0d4f9 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp @@ -1030,7 +1030,14 @@ void AuctionIntentExecutor::ApplyBid(const IpcMessage& in, // UpdateBid returns true for a normal bid. It can only return false if // newbid reaches buyout, which we excluded above, so for a pure bid this // is the OK path regardless of return value. - auction->UpdateBid(b.bidAmount, NULL); + bool applied = false; + auction->UpdateBid(b.bidAmount, NULL, &applied); + if (!applied) + { + ++m_rejected; + MakeResult(resultOut, b.uuid, INTENT_REJECTED, REASON_TRANSACTION); + return; + } ++m_applied; Remember(b.uuid, now); @@ -1143,7 +1150,14 @@ void AuctionIntentExecutor::ApplyBuyout(const IpcMessage& in, // pays nothing; UpdateBid returns false here (buyout reached) and deletes // the auction internally -- false is the SUCCESS path for buyout, so we // must NOT touch `auction` afterwards. - auction->UpdateBid(auction->buyout, NULL); + bool applied = false; + auction->UpdateBid(auction->buyout, NULL, &applied); + if (!applied) + { + ++m_rejected; + MakeResult(resultOut, b.uuid, INTENT_REJECTED, REASON_TRANSACTION); + return; + } ++m_applied; Remember(b.uuid, now); diff --git a/src/game/AuctionHouseBot/CustodyLedger.cpp b/src/game/AuctionHouseBot/CustodyLedger.cpp index fec206391..3fecc6dba 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.cpp +++ b/src/game/AuctionHouseBot/CustodyLedger.cpp @@ -30,6 +30,12 @@ #include #include "Database/DatabaseEnv.h" +namespace +{ + // World-thread only. Unknown startup state must never bypass custody. + bool s_mayHaveReservedRows = true; +} + /// Column order used by SELECT queries (matches the struct field order for /// LoadNonTerminal / Get): /// 0:id 1:idem_key 2:kind 3:role 4:state 5:owner_guid @@ -60,6 +66,11 @@ static void FillRow(Field* f, CustodyRow& row) void CustodyLedger::Insert(CustodyRow const& r) { + if (r.state == CST_RESERVED) + { + // Sticky even on rollback: a false positive only costs a route lookup. + s_mayHaveReservedRows = true; + } std::string key = r.idemKey; CharacterDatabase.escape_string(key); CharacterDatabase.PExecute( @@ -75,6 +86,10 @@ void CustodyLedger::Insert(CustodyRow const& r) void CustodyLedger::SetState(std::string const& idemKey, uint8 newState, uint64 resolvedTime) { + if (newState == CST_RESERVED) + { + s_mayHaveReservedRows = true; + } std::string key = idemKey; CharacterDatabase.escape_string(key); CharacterDatabase.PExecute( @@ -92,9 +107,22 @@ void CustodyLedger::SetAmount(std::string const& idemKey, uint32 newAmount) newAmount, key.c_str()); } +void CustodyLedger::InitializeRouting() +{ + QueryResult* result = CharacterDatabase.Query( + "SELECT EXISTS(SELECT 1 FROM `custody_ledger` WHERE `state`=0 LIMIT 1)"); + s_mayHaveReservedRows = !result || result->Fetch()[0].GetUInt32() != 0u; + delete result; +} + CustodyRouteState CustodyLedger::GetRouteState(uint32 auctionId) { CustodyRouteState route = {}; + if (!s_mayHaveReservedRows) + { + route.known = true; + return route; + } QueryResult* result = CharacterDatabase.PQuery( "SELECT " "COALESCE(MAX(`idem_key` LIKE 'botlist:%%'),0)," @@ -110,6 +138,7 @@ CustodyRouteState CustodyLedger::GetRouteState(uint32 auctionId) } Field* fields = result->Fetch(); + route.known = true; bool const hasMarker = fields[0].GetUInt32() != 0; bool const hasSellerCandidate = fields[1].GetUInt32() != 0; route.usesPlayerSellerCustody = hasSellerCandidate && !hasMarker; diff --git a/src/game/AuctionHouseBot/CustodyLedger.h b/src/game/AuctionHouseBot/CustodyLedger.h index 49639dc0b..35c80d886 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.h +++ b/src/game/AuctionHouseBot/CustodyLedger.h @@ -115,6 +115,7 @@ struct CustodySnapshotGroup struct CustodyRouteState { + bool known; ///< False on lookup failure; callers must not use legacy settlement. bool usesPlayerSellerCustody; bool hasLiveBidCustody; }; @@ -127,6 +128,8 @@ struct CustodyRouteState */ namespace CustodyLedger { + /// Initialize once at startup. Reservations latch routing on until restart. + void InitializeRouting(); CustodyRouteState GetRouteState(uint32 auctionId); void LoadReconcileSnapshot(std::vector& out); diff --git a/src/game/Object/AuctionHouseMgr.cpp b/src/game/Object/AuctionHouseMgr.cpp index 9293bdff5..3ad46e50d 100644 --- a/src/game/Object/AuctionHouseMgr.cpp +++ b/src/game/Object/AuctionHouseMgr.cpp @@ -1065,6 +1065,11 @@ void AuctionHouseObject::Update() // not settlement of value already represented by durable rows. CustodyRouteState const route = CustodyLedger::GetRouteState(old->second->Id); + if (!route.known) + { + sLog.outError("custody route unavailable; deferring auction expiry"); + return; + } ///- perform the transaction if there was bidder if (old->second->bid) @@ -1783,10 +1788,63 @@ void AuctionEntry::PrepareCancelCustody(Player* seller, CustodyDeferred& def, * * @param newbid The new bid amount. * @param newbidder The player placing the bid. + * @param applied Optional success output; false on a custody/commit failure. * @return true if the auction remains active after the update; otherwise, false. */ -bool AuctionEntry::UpdateBid(uint32 newbid, Player* newbidder /*=NULL*/) +bool AuctionEntry::UpdateBid(uint32 newbid, Player* newbidder /*=NULL*/, + bool* applied /*=NULL*/) { + if (applied) + { + *applied = false; + } + if (!newbidder) + { + // Both service intents and the in-process buyer enter here. Preserve + // player custody when a generated bid displaces its current owner. + CustodyRouteState const route = CustodyLedger::GetRouteState(Id); + if (!route.known) + { + return false; + } + if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) + { + std::string liveBidKey; + if (route.hasLiveBidCustody) + { + CustodyRow row; + if (bidder == 0u || !CustodyLedger::GetSingleLiveBidRow(Id, row) || + row.ownerGuid != bidder || row.amount != bid) + { + sLog.outError("custody bot bid validation failed for auction %u", + Id); + return false; + } + liveBidKey = row.idemKey; + } + uint32 const oldBid = bid; + uint32 const oldBidder = bidder; + CustodyDeferred def; + if (!CharacterDatabase.BeginTransaction()) + { + return false; + } + bool const active = UpdateBidCustody(newbid, NULL, def, + route.usesPlayerSellerCustody, route.hasLiveBidCustody, liveBidKey); + if (!CustodyService::CommitCheckedOrForcedFail("bot-bid")) + { + bid = oldBid; + bidder = oldBidder; + return false; + } + if (applied) + { + *applied = true; + } + def.run(); // A successful buyout deletes this auction last. + return active; + } + } Player* auction_owner = owner ? sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER, owner)) : NULL; // bid can't be greater buyout @@ -1825,10 +1883,18 @@ bool AuctionEntry::UpdateBid(uint32 newbid, Player* newbidder /*=NULL*/) newbidder->SaveInventoryAndGoldToDB(); } CharacterDatabase.CommitTransaction(); + if (applied) + { + *applied = true; + } return true; } else // buyout { + if (applied) + { + *applied = true; + } AuctionBidWinning(newbidder); return false; } @@ -1850,7 +1916,7 @@ bool AuctionEntry::UpdateBid(uint32 newbid, Player* newbidder /*=NULL*/) * active), true on a normal bid. * * @param newbid The new bid amount (capped at buyout here, as in UpdateBid). - * @param newbidder The player placing the bid (always non-NULL for the player seam). + * @param newbidder The bidding player, or NULL for a generated bot bid. * @param def Ordered deferred-effects queue for this co-commit. * @param liveBidKey idem_key of the existing live bid row (validated by the * handler), empty when the auction has no live bidder. @@ -1916,11 +1982,14 @@ bool AuctionEntry::UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDef // NextBidSeq returns MAX(id) of existing bid rows: monotonic, never // decreases after TTL pruning, so the suffix is always strictly greater // than every existing row's suffix -- UNIQUE constraint cannot fire. - std::string newBidKey = "bid:" + std::to_string(Id) + ":" + - std::to_string(CustodyLedger::NextBidSeq(Id)); - CustodyService::ReserveGold(def, newbidder ? newbidder->GetGUIDLow() : 0, - newbidder, newbid, newBidKey, Id, ROLE_BID); - winningBidKey = newBidKey; + if (newbidder) + { + std::string newBidKey = "bid:" + std::to_string(Id) + ":" + + std::to_string(CustodyLedger::NextBidSeq(Id)); + CustodyService::ReserveGold(def, newbidder->GetGUIDLow(), + newbidder, newbid, newBidKey, Id, ROLE_BID); + winningBidKey = newBidKey; + } } bidder = newbidder ? newbidder->GetGUIDLow() : 0; @@ -1938,11 +2007,11 @@ bool AuctionEntry::UpdateBidCustody(uint32 newbid, Player* newbidder, CustodyDef // Buyout: resolve the win on this same open transaction. The winner's gold is // already persisted (ReserveGold/TopUpBid above), so AuctionBidWinningCustody // does NOT re-save it. Pass winningBidKey so the bid-row commit-net does not - // SELECT for the uncommitted row (bidder==0 cannot happen here: a player buyout - // always has a live newbidder). The auction is deleted in a deferred closure - // run only after the caller's checked commit succeeds. + // SELECT for the uncommitted row. Generated buyers have no gold reservation. + // The auction is deleted in a deferred closure run only after the caller's + // checked commit succeeds. AuctionBidWinningCustody(newbidder, def, usesPlayerSellerCustody, - true, winningBidKey); + !winningBidKey.empty(), winningBidKey); return false; } diff --git a/src/game/Object/AuctionHouseMgr.h b/src/game/Object/AuctionHouseMgr.h index 533ea291d..24c21cf8f 100644 --- a/src/game/Object/AuctionHouseMgr.h +++ b/src/game/Object/AuctionHouseMgr.h @@ -146,7 +146,8 @@ struct AuctionEntry bool hasLiveBidCustody, std::string const& liveBidKey, uint32 auctionCut); - bool UpdateBid(uint32 newbid, Player* newbidder = NULL);// true if normal bid, false if buyout, bidder==NULL for generated bid + /// True if still active. Optional applied distinguishes failure from buyout. + bool UpdateBid(uint32 newbid, Player* newbidder = NULL, bool* applied = NULL); /// Custody co-commit mirror of UpdateBid: moves the bidder's gold via the /// custody primitives and appends every DB write to the caller's already-open /// CharacterDatabase transaction (the caller opens/commits it). Live effects diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index eb2ad0b49..49590bff4 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -1109,6 +1109,12 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recv_data) // durable rows already in flight remain authoritative until terminal. CustodyRouteState const route = CustodyLedger::GetRouteState(auction->Id); + if (!route.known) + { + SendAuctionCommandResultData(auction->Id, AUCTION_BID_PLACED, + AUCTION_ERR_DATABASE, EQUIP_ERR_OK, 0); + return; + } // A player-seller row or a player-bid row selects the combined transaction. // Marker-only bot listings remain entirely on the legacy path. if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) @@ -1296,6 +1302,12 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) // durable rows already in flight remain authoritative until terminal. CustodyRouteState const route = CustodyLedger::GetRouteState(auction->Id); + if (!route.known) + { + SendAuctionCommandResultData(auction->Id, AUCTION_REMOVED, + AUCTION_ERR_DATABASE, EQUIP_ERR_OK, 0); + return; + } if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) { // ------------------------------------------------------------------- diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index aaac99253..db169933c 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -765,6 +765,7 @@ void World::SetInitialWorldSettings() ///- Load dynamic data tables from the database sLog.outString("Loading Auctions..."); + CustodyLedger::InitializeRouting(); sAuctionMgr.LoadAuctionItems(); sAuctionMgr.LoadAuctions(); sLog.outString(">>> Auctions loaded"); diff --git a/src/ipc/AuctionIntents.h b/src/ipc/AuctionIntents.h index 95799dafe..6c23920ac 100644 --- a/src/ipc/AuctionIntents.h +++ b/src/ipc/AuctionIntents.h @@ -81,7 +81,8 @@ enum IntentReason : uint8 REASON_STALE_BID = 4, ///< Bid is below current bid REASON_GUID_MISMATCH = 5, ///< botGuid does not own the auction REASON_NO_FUNDS = 6, ///< Insufficient funds for bid/buyout - REASON_BAD_ITEM = 7 ///< Item ID invalid or not listable + REASON_BAD_ITEM = 7, ///< Item ID invalid or not listable + REASON_TRANSACTION = 8 ///< Custody validation or checked commit failed }; // --------------------------------------------------------------------------- diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index f4df54227..72164a20c 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -4723,6 +4723,125 @@ static int RunAhCustodyRouteTest() } } + // Bot bids share the legacy UpdateBid entry point with the fallback buyer. + { + uint32 const auctionId = 995130u; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, bidderGuid, 100u, 0u, 20u); + if (!auction) + { + return 2; + } + seedRow("bid:995130:1", CUSTODY_GOLD, ROLE_BID, + bidderGuid, 100u, 0u, auctionId); + uint32 const refunds = mailCount(bidderGuid, AUCTION_OUTBIDDED); + uint64 const refundMoney = mailMoney(bidderGuid, AUCTION_OUTBIDDED); + auction->UpdateBid(120u); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.CommitTransactionChecked(); + if (rowState("bid:995130:1") != CST_TERMINAL_BACK || + auction->bidder != 0u || auction->bid != 120u || + mailCount(bidderGuid, AUCTION_OUTBIDDED) != refunds + 1u || + mailMoney(bidderGuid, AUCTION_OUTBIDDED) != refundMoney + 100u) + { + printf("ahcustodyroute FAIL: bot displacement stranded player bid\n"); + pass = false; + } + AuctionHouseObject* map = sAuctionMgr.GetAuctionsMap(&house); + map->AddAuction(auction); + auction->expireTime = static_cast(-1); + map->Update(); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.CommitTransactionChecked(); + if (auctionExists(auctionId)) + { + printf("ahcustodyroute FAIL: bot-held auction could not expire\n"); + pass = false; + } + if (AuctionEntry* leftover = map->GetAuction(auctionId)) + { + map->RemoveAuction(auctionId); + delete leftover; + } + } + + // A failed bot buyout must roll back refund, seller payout, book and escrow. + { + uint32 const auctionId = 995131u; + AuctionEntry* auction = createAuction( + auctionId, sellerGuid, bidderGuid, 100u, 200u, 20u); + if (!auction) + { + return 2; + } + uint32 const itemGuid = auction->itemGuidLow; + Item* const cached = sAuctionMgr.GetAItem(itemGuid); + seedRow("bid:995131:1", CUSTODY_GOLD, ROLE_BID, + bidderGuid, 100u, 0u, auctionId); + seedRow("item:995131", CUSTODY_ITEM, ROLE_ITEM, + sellerGuid, 0u, itemGuid, auctionId); + seedRow("dep:995131", CUSTODY_GOLD, ROLE_DEPOSIT, + sellerGuid, 20u, 0u, auctionId); + uint32 const refunds = mailCount(bidderGuid, AUCTION_OUTBIDDED); + uint64 const refundMoney = mailMoney(bidderGuid, AUCTION_OUTBIDDED); + uint32 const sales = mailCount(sellerGuid, AUCTION_SUCCESSFUL); + uint64 const salesMoney = mailMoney(sellerGuid, AUCTION_SUCCESSFUL); + uint32 const payout = 220u - uint32(house.cutPercent * 200u * + sWorld.getConfig(CONFIG_FLOAT_RATE_AUCTION_CUT) / 100.0f); + AuctionHouseObject* map = sAuctionMgr.GetAuctionsMap(&house); + map->AddAuction(auction); + std::string originalConfig; + std::string testConfig; + if (!TestArmCustodyCommitFailure("bot-bid", originalConfig, testConfig)) + { + TestRestoreConfig(originalConfig, testConfig); + return 2; + } + auction->UpdateBid(200u); + bool const restored = TestRestoreConfig(originalConfig, testConfig); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.CommitTransactionChecked(); + AuctionEntry* retry = map->GetAuction(auctionId); + if (!restored || !retry || !auctionExists(auctionId) || + retry->bidder != bidderGuid || retry->bid != 100u || + sAuctionMgr.GetAItem(itemGuid) != cached || + rowState("bid:995131:1") != CST_RESERVED || + rowState("item:995131") != CST_RESERVED || + rowState("dep:995131") != CST_RESERVED || + mailCount(bidderGuid, AUCTION_OUTBIDDED) != refunds || + mailCount(sellerGuid, AUCTION_SUCCESSFUL) != sales) + { + printf("ahcustodyroute FAIL: failed bot buyout moved value\n"); + pass = false; + } + if (retry) + { + retry->UpdateBid(200u); + CharacterDatabase.BeginTransaction(); + CharacterDatabase.CommitTransactionChecked(); + std::unique_ptr itemRow(CharacterDatabase.PQuery( + "SELECT 1 FROM `item_instance` WHERE `guid`=%u", itemGuid)); + if (auctionExists(auctionId) || map->GetAuction(auctionId) || + sAuctionMgr.GetAItem(itemGuid) || itemRow || + rowState("bid:995131:1") != CST_TERMINAL_BACK || + rowState("item:995131") != CST_TERMINAL_OK || + rowState("dep:995131") != CST_TERMINAL_BACK || + mailCount(bidderGuid, AUCTION_OUTBIDDED) != refunds + 1u || + mailMoney(bidderGuid, AUCTION_OUTBIDDED) != refundMoney + 100u || + mailCount(sellerGuid, AUCTION_SUCCESSFUL) != sales + 1u || + mailMoney(sellerGuid, AUCTION_SUCCESSFUL) != salesMoney + payout) + { + printf("ahcustodyroute FAIL: bot buyout retry did not settle once\n"); + pass = false; + } + } + if (AuctionEntry* leftover = map->GetAuction(auctionId)) + { + map->RemoveAuction(auctionId); + delete leftover; + } + } + for (size_t i = 0; i < itemGuids.size(); ++i) { Item* liveItem = sAuctionMgr.GetAItem(itemGuids[i]); @@ -4753,6 +4872,80 @@ static int RunAhCustodyRouteTest() return 2; } +/// No-work routing must avoid SQL, while reservations survive config disable. +static int RunAhRouteGateTest() +{ + CharacterDatabase.AllowAsyncTransactions(); + std::unique_ptr count(CharacterDatabase.Query( + "SELECT COUNT(*) FROM `custody_ledger` WHERE `state`=0")); + if (!count || count->Fetch()[0].GetUInt64() != 0u) + { + printf("ahroutegate FAIL: requires an empty disposable ledger\n"); + return 2; + } + bool pass = true; + CustodyLedger::InitializeRouting(); + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `custody_ledger` TO `custody_route_test_unavailable`")) + { + return 2; + } + // If routing attempts SQL despite known-empty startup, this becomes unknown. + CustodyRouteState const empty = CustodyLedger::GetRouteState(995190u); + bool const restored = CharacterDatabase.DirectExecute( + "RENAME TABLE `custody_route_test_unavailable` TO `custody_ledger`"); + if (!restored) + { + printf("ahroutegate FAIL: could not restore ledger table\n"); + return 2; + } + if (!empty.known || empty.usesPlayerSellerCustody || empty.hasLiveBidCustody) + { + printf("ahroutegate FAIL: known-empty route attempted SQL\n"); + pass = false; + } + CharacterDatabase.BeginTransaction(); + CustodyLedger::Insert(TestCustodyRow(0, "test:route-gate", CUSTODY_GOLD, + ROLE_BID, 9502u, 100u, 0u, 995190u)); + if (!CharacterDatabase.CommitTransactionChecked()) + { + return 2; + } + bool const wasEnabled = sWorld.IsAhCustodyEnabled(); + sWorld.setConfig(CONFIG_BOOL_AH_CUSTODY, false); + CustodyRouteState const inserted = CustodyLedger::GetRouteState(995190u); + CustodyLedger::InitializeRouting(); + CustodyRouteState const restarted = CustodyLedger::GetRouteState(995190u); + sWorld.setConfig(CONFIG_BOOL_AH_CUSTODY, wasEnabled); + if (!inserted.known || !inserted.hasLiveBidCustody || + !restarted.known || !restarted.hasLiveBidCustody) + { + printf("ahroutegate FAIL: reservation lost after insertion/restart/disable\n"); + pass = false; + } + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `custody_ledger` TO `custody_route_test_unavailable`")) + { + return 2; + } + CustodyLedger::InitializeRouting(); + CustodyRouteState const failed = CustodyLedger::GetRouteState(995190u); + if (!CharacterDatabase.DirectExecute( + "RENAME TABLE `custody_route_test_unavailable` TO `custody_ledger`")) + { + return 2; + } + if (failed.known) + { + printf("ahroutegate FAIL: failed lookup treated as known legacy route\n"); + pass = false; + } + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key`='test:route-gate'"); + printf("ahroutegate %s\n", pass ? "OK" : "FAIL"); + return pass ? 0 : 2; +} + /// Regression for a real SP-2 smoke failure: the worker committed a cancel and /// removed the auction, but mangosd missed the terminal result before restart. /// The pending map is then empty, so repair must replay from ah_worker_journal @@ -6298,6 +6491,10 @@ int RunMangosdTest(std::string const& name) { return RunAhCustodyRouteTest(); } + if (name == "ahroutegate") + { + return RunAhRouteGateTest(); + } if (name == "ahreconcile") { From 8b1d55dc9ba5abdaee8bd2eb19b9f7845aff7771 Mon Sep 17 00:00:00 2001 From: MadMax Date: Tue, 8 Sep 2026 18:34:33 +0100 Subject: [PATCH 18/18] fix(ah): require deposit custody for terminal worker resolutions --- .../WorldHandlers/AuctionHouseHandler.cpp | 6 +- src/mangosd/MangosdTest.cpp | 122 ++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/game/WorldHandlers/AuctionHouseHandler.cpp b/src/game/WorldHandlers/AuctionHouseHandler.cpp index 49590bff4..b68414e55 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -3330,10 +3330,10 @@ uint8 AhHandleResolveApply(ResolveApply const& ra) (ra.kind == uint8(RESOLVE_WON) || ra.kind == uint8(RESOLVE_EXPIRED_NOBID) || (ra.kind == uint8(RESOLVE_REPAIR_RETURN) && !repairRefundOnly)); - if (needsItem && !AhPreflightTerminalItem(f, false)) + if (needsItem && !AhPreflightTerminalItem(f, true)) { - sLog.outError("[AHMut] resolve kind %u auction %u missing terminal " - "item; RES_FAILED", uint32(ra.kind), f.auctionId); + sLog.outError("[AHMut] resolve kind %u auction %u invalid terminal " + "item or deposit custody; RES_FAILED", uint32(ra.kind), f.auctionId); return uint8(RES_FAILED); } diff --git a/src/mangosd/MangosdTest.cpp b/src/mangosd/MangosdTest.cpp index 72164a20c..b50bbd3e6 100644 --- a/src/mangosd/MangosdTest.cpp +++ b/src/mangosd/MangosdTest.cpp @@ -3598,6 +3598,128 @@ static int RunAhResolveTest() return res ? res->Fetch()[0].GetUInt64() : 0; }; + // Terminal player resolutions require the matching deposit before any leg. + uint8 const terminalKinds[] = + { RESOLVE_WON, RESOLVE_EXPIRED_NOBID, RESOLVE_REPAIR_RETURN }; + for (uint32 mode = 0u; mode < 3u; ++mode) + { + for (uint32 mismatch = 0u; mismatch < 2u; ++mismatch) + { + uint32 const auctionId = 991010u + mode * 2u + mismatch; + std::string const depKey = "dep:" + std::to_string(auctionId); + std::string const itemKey = "item:" + std::to_string(auctionId); + std::string const bidKey = "bid:" + std::to_string(auctionId) + ":1"; + Item* const item = TestCreateCachedAuctionItem(19019u, 2u); + if (!item) + { + return 2; + } + uint32 const itemGuid = item->GetGUIDLow(); + CharacterDatabase.BeginTransaction(); + CustodyLedger::Insert(TestCustodyRow(0, itemKey, CUSTODY_ITEM, + ROLE_ITEM, 2u, 0u, itemGuid, auctionId)); + if (mode == 0u) + { + CustodyLedger::Insert(TestCustodyRow(0, bidKey, CUSTODY_GOLD, + ROLE_BID, 1u, 200u, 0u, auctionId)); + } + if (mismatch) + { + CustodyLedger::Insert(TestCustodyRow(0, depKey, CUSTODY_GOLD, + ROLE_DEPOSIT, 2u, 31u, 0u, auctionId)); + } + if (!CharacterDatabase.CommitTransactionChecked()) + { + return 2; + } + ResolveApply ra = {}; + ra.uuid = 9912000ull + auctionId; + ra.kind = terminalKinds[mode]; + ra.facts.auctionId = auctionId; + ra.facts.houseId = 7u; + ra.facts.sellerGuid = 2u; + ra.facts.itemGuid = itemGuid; + ra.facts.itemTemplate = 19019u; + ra.facts.itemCount = 1u; + ra.facts.deposit = 32u; + if (mode == 0u) + { + ra.facts.curBidderGuid = 1u; + ra.facts.curBid = ra.facts.effectiveBid = 200u; + } + uint64 const wallet = readMoney(); + uint8 const result = AhHandleResolveApply(ra); + std::unique_ptr heldMail(CharacterDatabase.Query( + "SELECT COUNT(*) FROM `mail` WHERE `receiver` IN (1,2)")); + bool const held = result == uint8(RES_FAILED) && + !CustodyService::ResolutionApplied(ra.uuid) && + sAuctionMgr.GetAItem(itemGuid) == item && + rowState(itemKey.c_str()) == CST_RESERVED && + rowState(depKey.c_str()) == (mismatch ? CST_RESERVED : 255u) && + (mode != 0u || rowState(bidKey.c_str()) == CST_RESERVED) && + readMoney() == wallet && heldMail && + heldMail->Fetch()[0].GetUInt64() == 0u; + if (!held) + { + printf("ahresolve FAIL: kind %u %s deposit moved value\n", + uint32(ra.kind), mismatch ? "mismatched" : "missing"); + pass = false; + } + else + { + CharacterDatabase.BeginTransaction(); + if (mismatch) + { + CustodyLedger::SetAmount(depKey, 32u); + } + else + { + CustodyLedger::Insert(TestCustodyRow(0, depKey, CUSTODY_GOLD, + ROLE_DEPOSIT, 2u, 32u, 0u, auctionId)); + } + if (!CharacterDatabase.CommitTransactionChecked()) + { + return 2; + } + uint8 const retried = AhHandleResolveApply(ra); + uint8 const duplicate = AhHandleResolveApply(ra); + std::unique_ptr mails(CharacterDatabase.Query( + "SELECT COUNT(*),COALESCE(SUM(`money`),0) " + "FROM `mail` WHERE `receiver` IN (1,2)")); + std::unique_ptr delivery(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `mail_items` " + "WHERE `item_guid`=%u AND `receiver`=%u", + itemGuid, mode == 0u ? 1u : 2u)); + if (retried != uint8(RES_APPLIED) || + duplicate != uint8(RES_DUPLICATE) || + !CustodyService::ResolutionApplied(ra.uuid) || + rowState(depKey.c_str()) != CST_TERMINAL_OK || + rowState(itemKey.c_str()) != CST_TERMINAL_OK || + (mode == 0u && rowState(bidKey.c_str()) != CST_TERMINAL_OK) || + !mails || mails->Fetch()[0].GetUInt64() != (mode == 0u ? 2u : 1u) || + mails->Fetch()[1].GetUInt64() != (mode == 0u ? 232u : 0u) || + !delivery || delivery->Fetch()[0].GetUInt64() != 1u) + { + printf("ahresolve FAIL: restored deposit did not settle once\n"); + pass = false; + } + } + if (Item* leftover = sAuctionMgr.GetAItem(itemGuid)) + { + sAuctionMgr.RemoveAItem(itemGuid); + delete leftover; + } + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail_items` WHERE `receiver` IN (1,2)"); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver` IN (1,2)"); + CharacterDatabase.DirectPExecute( + "DELETE FROM `item_instance` WHERE `guid`=%u", itemGuid); + } + } + // ---- RESOLVE_WON: missing item cache must hold every value leg ---- { CharacterDatabase.BeginTransaction();