diff --git a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp index 9d9258865..d3af0d4f9 100644 --- a/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp +++ b/src/game/AuctionHouseBot/AuctionIntentExecutor.cpp @@ -23,12 +23,15 @@ #include #include #include +#include +#include #include "AuctionIntentExecutor.h" #include "AuctionIntents.h" #include "AuctionHouseMgr.h" #include "AuctionHouseBot.h" #include "CustodyLedger.h" +#include "CustodyService.h" #include "ObjectMgr.h" #include "ItemPrototype.h" #include "Item.h" @@ -722,8 +725,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; @@ -733,54 +745,132 @@ 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). + // 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 `idem_key`, `item_guid`, `auction_id`, `owner_guid` " - "FROM `custody_ledger` " - "WHERE `idem_key` LIKE 'botlist:%%' AND `created_time` < " UI64FMTD " " - "AND `auction_id` NOT IN (SELECT `id` FROM `auction`)", - cutoff); + "(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 ") UNION ALL SELECT 0,0,0", + cutoff, queryLimit); if (q == NULL) { - return; + report.committed = false; + sLog.outError("[AHExecutor] orphan materialization candidate query " + "failed; sweep will retry"); + return report; } + struct Candidate + { + uint32 ledgerId; + uint32 itemGuid; + uint32 ownerGuid; + }; + std::vector candidates; + candidates.reserve(maxRows); do { 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); + if (f[0].GetUInt32() == 0u) + { + continue; + } + if (candidates.size() == maxRows) + { + report.morePending = true; + break; + } + + 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 (!CustodyService::CommitCheckedOrForcedFail("orphan-sweep")) + { + 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. + // 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); + if (orphan && orphan->GetOwnerGuid().GetCounter() == it->ownerGuid) + { + sAuctionMgr.RemoveAItem(it->itemGuid); + delete orphan; + } + } + 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, @@ -940,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); @@ -1053,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/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/AuctionHouseBot/CustodyLedger.cpp b/src/game/AuctionHouseBot/CustodyLedger.cpp index 5120071dd..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,21 +107,112 @@ void CustodyLedger::SetAmount(std::string const& idemKey, uint32 newAmount) newAmount, key.c_str()); } -bool CustodyLedger::HasRows(uint32 auctionId) +void CustodyLedger::InitializeRouting() { - // 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) + 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) { - return false; + route.known = true; + return route; } - delete res; - return true; + 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(); + route.known = true; + 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 COUNT(*) FROM `auction` WHERE `id`=%u", auctionId); + if (!result) + { + // Repair must positively establish absence before moving custody. + return true; + } + bool const exists = result->Fetch()[0].GetUInt64() != 0u; + delete result; + return exists; } void CustodyLedger::LoadNonTerminal(std::vector& out) @@ -148,15 +254,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 7d65224ae..35c80d886 100644 --- a/src/game/AuctionHouseBot/CustodyLedger.h +++ b/src/game/AuctionHouseBot/CustodyLedger.h @@ -95,6 +95,31 @@ 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 known; ///< False on lookup failure; callers must not use legacy settlement. + bool usesPlayerSellerCustody; + bool hasLiveBidCustody; +}; + /** * @brief Persistent CRUD namespace for the custody_ledger table. * @@ -103,6 +128,15 @@ struct CustodyRow */ namespace CustodyLedger { + /// Initialize once at startup. Reservations latch routing on until restart. + void InitializeRouting(); + CustodyRouteState GetRouteState(uint32 auctionId); + + void LoadReconcileSnapshot(std::vector& out); + + /// Conservative liveness guard: query failure also returns true. + bool AuctionExists(uint32 auctionId); + /** * @brief Append an INSERT for @p row to the caller's open transaction. * @@ -130,22 +164,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. * @@ -179,9 +197,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/AuctionHouseBot/CustodyReconciler.cpp b/src/game/AuctionHouseBot/CustodyReconciler.cpp new file mode 100644 index 000000000..5f275172a --- /dev/null +++ b/src/game/AuctionHouseBot/CustodyReconciler.cpp @@ -0,0 +1,518 @@ +/** + * 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 + * + * 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" + +#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->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; + } + 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, + 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 << ',' + << 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(); + } + + 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; + } +} + +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) +{ + 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..ab487f6bc --- /dev/null +++ b/src/game/AuctionHouseBot/CustodyReconciler.h @@ -0,0 +1,127 @@ +/** + * 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 + * + * 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 +#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 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: + 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..1124157b7 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, @@ -402,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 @@ -412,60 +244,87 @@ 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; } return CharacterDatabase.CommitTransactionChecked(); } -void CustodyService::ReconcileScan(bool dryRun, std::vector& orphans) +void CustodyService::ReconcileScan(uint64 now, CustodyScanContext context, + CustodyReconcileReport& report) { - (void)dryRun; + std::vector snapshot; + CustodyLedger::LoadReconcileSnapshot(snapshot); + s_reconciler.Scan(snapshot, now, context, report); +} - uint64 const now = static_cast(time(NULL)); - std::vector nonTerminal; - CustodyLedger::LoadNonTerminal(nonTerminal); +bool CustodyService::ReconcileFindingIsError(CustodyFinding const& finding) +{ + return finding.state == CUSTODY_FINDING_CONFIRMED && + finding.repairOwnership != CUSTODY_REPAIR_BOT_SWEEP; +} - for (size_t i = 0; i < nonTerminal.size(); ++i) +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) { - CustodyRow const& row = nonTerminal[i]; - if (!IsMature(row, now)) + CustodyFinding const& finding = report.findings[i]; + if (!budget.Take()) { continue; } - if (!FindLiveAuction(row.auctionId)) + if (ReconcileFindingIsError(finding)) { - AddDrift(orphans, row, "orphan row"); + 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)); } } - for (uint32 i = 0; i < MAX_AUCTION_HOUSE_TYPE; ++i) + if (budget.Suppressed()) { - AuctionHouseObject* auctions = sAuctionMgr.GetAuctionsMap(AuctionHouseType(i)); - AuctionHouseObject::AuctionEntryMap const& map = auctions->GetAuctions(); - for (AuctionHouseObject::AuctionEntryMap::const_iterator itr = map.begin(); itr != map.end(); ++itr) - { - AuctionEntry const* auction = itr->second; - if (!HasMatureCustodyRow(nonTerminal, auction->Id, now)) - { - continue; - } - - CheckAuctionExpectedRows(auction, nonTerminal, orphans); - } + 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 6d84fea89..39ffd5622 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 @@ -227,33 +228,37 @@ 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. 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); - /** - * @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. - */ - void ReconcileScan(bool dryRun, std::vector& orphans); + /// 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 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); } #endif // MANGOS_CUSTODY_SERVICE_H 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/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/game/Object/AuctionHouseMgr.cpp b/src/game/Object/AuctionHouseMgr.cpp index 2dcfeba02..3ad46e50d 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); } /** @@ -1059,19 +1061,49 @@ void AuctionHouseObject::Update() AuctionEntryMap::iterator old = itr++; if (curTime > old->second->expireTime) { + // 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); + if (!route.known) + { + sLog.outError("custody route unavailable; deferring auction expiry"); + return; + } + ///- 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()) { @@ -1096,13 +1128,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(); @@ -1592,11 +1623,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 @@ -1606,6 +1635,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 @@ -1616,38 +1647,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 -> @@ -1655,13 +1666,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(); @@ -1722,15 +1730,121 @@ 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); + MANGOS_ASSERT(item); + 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. * * @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 @@ -1769,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; } @@ -1794,13 +1916,15 @@ 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. * @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 @@ -1821,12 +1945,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 { @@ -1836,10 +1970,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); } @@ -1848,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; @@ -1870,10 +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. - AuctionBidWinningCustody(newbidder, def, winningBidKey); + // 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, + !winningBidKey.empty(), winningBidKey); return false; } diff --git a/src/game/Object/AuctionHouseMgr.h b/src/game/Object/AuctionHouseMgr.h index c7d06fecc..24c21cf8f 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,7 +138,16 @@ 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); - bool UpdateBid(uint32 newbid, Player* newbidder = NULL);// true if normal bid, false if buyout, bidder==NULL for generated bid + /// 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); + /// 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 @@ -146,11 +155,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 +166,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..b68414e55 100644 --- a/src/game/WorldHandlers/AuctionHouseHandler.cpp +++ b/src/game/WorldHandlers/AuctionHouseHandler.cpp @@ -1105,10 +1105,19 @@ 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)) + // 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.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) { // Custody co-commit path. The success-path SendAuctionCommandResult is // deferred and appended FIRST (its legacy position :507 precedes @@ -1116,15 +1125,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 +1180,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 +1298,17 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recv_data) return; } - if (sWorld.IsAhCustodyEnabled() && CustodyLedger::HasRows(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.known) + { + SendAuctionCommandResultData(auction->Id, AUCTION_REMOVED, + AUCTION_ERR_DATABASE, EQUIP_ERR_OK, 0); + return; + } + if (route.usesPlayerSellerCustody || route.hasLiveBidCustody) { // ------------------------------------------------------------------- // Custody co-commit path (per-auction drain, X3). One checked txn: @@ -1306,17 +1327,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 +1347,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 +1355,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 +1373,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 @@ -2186,21 +2158,86 @@ static std::string AhMailSubject(uint32 itemTemplate, int32 itemRand, uint32 res return s.str(); } -// [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) +// 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, + uint8 expectedRole = ROLE_ITEM) +{ + item = NULL; + if (key.empty() || !CustodyLedger::Get(key, row) || + row.kind != CUSTODY_ITEM || row.role != expectedRole || + 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 || item->GetOwnerGuid().GetCounter() != ownerGuid) + { + sLog.outError("[AHMut] auction %u escrow item %u missing/mismatched in cache; " + "holding custody for retry", + auctionId, row.itemGuid); + return false; + } + return true; +} + +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 @@ -2209,27 +2246,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 @@ -2336,8 +2354,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); @@ -2505,16 +2523,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; } @@ -2700,6 +2718,13 @@ static bool AhFinalizeBidOk(PlayerMutationResult const& res, PendingMutation con } uint32 const remainder = pm.reservedAmount - needed; + if (isBuyoutWin && !AhPreflightTerminalItem(f, true)) + { + 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) @@ -2801,6 +2826,14 @@ static bool AhFinalizeCancelOk(PlayerMutationResult const& res, PendingMutation { MutationFacts const& f = res.facts; + if (!AhPreflightTerminalItem(f, true)) + { + 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) { @@ -2890,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; @@ -3049,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); } } @@ -3239,8 +3290,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; @@ -3272,6 +3323,20 @@ 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 && !AhPreflightTerminalItem(f, true)) + { + 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); + } + CustodyDeferred def; // [F1] A RESOLVE_CANCELLED_UNLOCK release credits an ONLINE owner's wallet // immediately (in-memory ModifyMoney, non-transactional); only the ledger @@ -3294,23 +3359,44 @@ 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::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) { 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); @@ -3451,13 +3537,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 @@ -3471,41 +3558,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; @@ -3522,11 +3603,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; @@ -3537,7 +3625,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)); @@ -3572,87 +3660,129 @@ 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) + // 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) && !committedCancel)) { - 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 + 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(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()) { @@ -3663,7 +3793,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(); } @@ -3674,6 +3805,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; } } } @@ -3692,12 +3824,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(); @@ -3713,44 +3901,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; + } + + std::map::iterator current = itr++; + PendingMutation pm; + if (!pend.Peek(current->first, pm)) + { + s_ahReconnectRetries.erase(current); continue; } - if (rd == AHJRN_FOUND) + AhReconnectDisposition const disposition = AhReconcilePending(pm); + if (disposition == AH_RECONNECT_RETRY) { - // 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); + 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 8cf6a4639..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"); @@ -1271,42 +1272,60 @@ 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) { - std::vector drift; - CustodyService::ReconcileScan(true, drift); - if (!drift.empty()) + 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); } - // 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. - if (IsAhWriteAuthority()) + 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(); @@ -1396,7 +1415,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/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 1341494b8..b50bbd3e6 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" @@ -12,6 +13,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" @@ -22,19 +24,50 @@ #include "Item.h" #include "BrowseMessages.h" #include "World.h" +#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); -bool AhRepairCommittedCancelAuction(uint32 auctionId, uint32& repairedRows); +bool AhRepairFindingMutationAllowed(CustodyFinding const& finding); + +struct TestCliCapture +{ + std::vector chunks; +}; + +static void TestCliPrint(void* arg, char const* text) +{ + if (arg && text) + { + static_cast(arg)->chunks.push_back(text); + } +} -static void TestCliPrint(void* /*arg*/, char const* /*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) @@ -51,6 +84,633 @@ 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; + } + + 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)); + 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; + } + } + + // 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; + } + } + + // 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); + 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; +} + +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. @@ -246,12 +906,22 @@ 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() { 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. @@ -311,15 +981,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; } @@ -396,81 +1068,183 @@ static int RunCustodyTest() "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:crud%%'"); CharacterDatabase.CommitTransactionChecked(); - // ================================================================ 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; - uint64 const now = static_cast(time(NULL)); - uint64 const oldTime = now > 7200 ? now - 7200 : 1; + // =================================================== 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.DirectExecute( - "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:recon:%'"); - CharacterDatabase.DirectExecute( - "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.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 ('test:recon:orphan','%u','%u','%u','%u','0','0','0','970001','" UI64FMTD "','0')", + "`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); + + if (!RunPureCustodyReconcilerTests()) + { + pass = false; + } + + // ================================================================ reconcile + // Task 13: ReconcileScan flags custody drift and DeleteTerminalOlderThan + // prunes only old terminal rows. + uint32 const liveAuctionId = 970002; + uint32 const missingItemAuctionId = 970003; + uint32 const duplicateBidAuctionId = 970006; + uint64 const now = static_cast(time(NULL)); + uint64 const oldTime = now > 7200 ? now - 7200 : 1; + + CharacterDatabase.DirectExecute( + "DELETE FROM `custody_ledger` WHERE `idem_key` LIKE 'test:recon:%'"); + CharacterDatabase.DirectExecute( + "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')"); + 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`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:recon:orphan','%u','%u','%u','%u','0','0','0','970001','" UI64FMTD "','0')", uint32(CUSTODY_GOLD), uint32(ROLE_DEPOSIT), uint32(CST_RESERVED), 1000, oldTime); CharacterDatabase.PExecute( "INSERT INTO `custody_ledger` " @@ -533,34 +1307,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) @@ -578,9 +1353,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; } } @@ -607,13 +1383,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( @@ -759,6 +1531,256 @@ 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); + 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 || !blockedOnQueryFailure || + !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. @@ -1731,15 +2753,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 @@ -1904,6 +2942,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` " @@ -1911,7 +2957,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"); @@ -1939,7 +2985,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 @@ -2053,13 +3099,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"); @@ -2087,7 +3141,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; @@ -2112,107 +3166,255 @@ 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 ('test:mut:rej',0,1,0,1,0,555,0,990001,0,0)"); + "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 (rej)\n"); + printf("ahmutresult FAIL: seed commit (rejected sell)\n"); return 2; } PendingMutation pm; - pm.uuid = 0xA2ull; + pm.uuid = 0xABull; pm.playerGuidLow = 1u; - pm.op = uint16(IPC_PLAYER_BUYOUT); - pm.auctionId = 990001u; + pm.op = uint16(IPC_PLAYER_SELL); + pm.auctionId = 990006u; pm.state = uint8(PMUT_AWAIT_RESULT); pm.sentSec = uint32(time(NULL)); - pm.reservedAmount = 555u; - pm.reserveKey = "test:mut:rej"; - pm.itemKey.clear(); - pm.depKey.clear(); + // 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 = 0xA2ull; - res.op = uint8(IPC_PLAYER_BUYOUT & 0xFFu); + res.uuid = pm.uuid; + res.op = uint8(IPC_PLAYER_SELL & 0xFFu); res.status = uint8(MUT_REJECTED); - res.reason = uint8(AUCTION_ERR_BID_INCREMENT); + res.reason = uint8(AUCTION_ERR_DATABASE); res.facts = MutationFacts(); - res.facts.auctionId = 990001u; + res.facts.auctionId = pm.auctionId; + res.facts.houseId = 7u; AhHandlePlayerMutationResult(res); - if (rowState("test:mut:rej") != 2u) + if (readMoney() != before + 73u) { - printf("ahmutresult FAIL: rejected row not TERMINAL_BACK\n"); + printf("ahmutresult FAIL: rejected sell did not refund durable " + "deposit amount\n"); pass = false; } - if (readMoney() != before + 555u) + if (rowState("dep:990006") != CST_TERMINAL_BACK || + rowState("item:990006") != CST_TERMINAL_BACK) { - printf("ahmutresult FAIL: rejected release not credited\n"); + printf("ahmutresult FAIL: rejected sell custody not " + "TERMINAL_BACK\n"); pass = false; } - PendingMutation gone; - if (pend.Peek(0xA2ull, gone)) + 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 pending not consumed\n"); + printf("ahmutresult FAIL: rejected sell item-return mail " + "missing\n"); pass = false; } } - // ---- Part C: MUT_REJECTED_STALE cancel -> release cut + resolve ---- + // ---- 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 ('test:mut:cut',0,2,0,1,0,55,0,990001,0,0)"); + "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 (cut)\n"); + printf("ahmutresult FAIL: seed commit " + "(missing rejected-sell item)\n"); return 2; } PendingMutation pm; - pm.uuid = 0xA3ull; + pm.uuid = 0xACull; pm.playerGuidLow = 1u; - pm.op = uint16(IPC_PLAYER_CANCEL); - pm.auctionId = 990001u; + 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.clear(); - pm.depKey.clear(); + pm.itemKey = "item:990007"; + pm.depKey = "dep:990007"; pend.Register(pm); - pend.RearmConfirm(0xA3ull, uint32(time(NULL))); - if (!pend.SetReserve(0xA3ull, 55u, "test:mut:cut")) - { - printf("ahmutresult FAIL: SetReserve\n"); - pass = false; - } uint64 const before = readMoney(); - PlayerMutationResult res; - res.uuid = 0xA3ull; - res.op = uint8(IPC_PLAYER_CANCEL & 0xFFu); - res.status = uint8(MUT_REJECTED_STALE); - res.reason = 0; + 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 = 990001u; + res.facts.auctionId = pm.auctionId; + res.facts.houseId = 7u; AhHandlePlayerMutationResult(res); - if (rowState("test:mut:cut") != 2u) + 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( + "INSERT INTO `custody_ledger` " + "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," + "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " + "VALUES ('test:mut:rej',0,1,0,1,0,555,0,990001,0,0)"); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahmutresult FAIL: seed commit (rej)\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = 0xA2ull; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_BUYOUT); + pm.auctionId = 990001u; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 555u; + pm.reserveKey = "test:mut:rej"; + pm.itemKey.clear(); + pm.depKey.clear(); + pend.Register(pm); + + uint64 const before = readMoney(); + + PlayerMutationResult res; + res.uuid = 0xA2ull; + res.op = uint8(IPC_PLAYER_BUYOUT & 0xFFu); + res.status = uint8(MUT_REJECTED); + res.reason = uint8(AUCTION_ERR_BID_INCREMENT); + res.facts = MutationFacts(); + res.facts.auctionId = 990001u; + AhHandlePlayerMutationResult(res); + + if (rowState("test:mut:rej") != 2u) + { + printf("ahmutresult FAIL: rejected row not TERMINAL_BACK\n"); + pass = false; + } + if (readMoney() != before + 555u) + { + printf("ahmutresult FAIL: rejected release not credited\n"); + pass = false; + } + PendingMutation gone; + if (pend.Peek(0xA2ull, gone)) + { + printf("ahmutresult FAIL: rejected pending not consumed\n"); + pass = false; + } + } + + // ---- Part C: MUT_REJECTED_STALE cancel -> release cut + resolve ---- + { + 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:mut:cut',0,2,0,1,0,55,0,990001,0,0)"); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahmutresult FAIL: seed commit (cut)\n"); + return 2; + } + + PendingMutation pm; + pm.uuid = 0xA3ull; + pm.playerGuidLow = 1u; + pm.op = uint16(IPC_PLAYER_CANCEL); + pm.auctionId = 990001u; + pm.state = uint8(PMUT_AWAIT_RESULT); + pm.sentSec = uint32(time(NULL)); + pm.reservedAmount = 0u; + pm.reserveKey.clear(); + pm.itemKey.clear(); + pm.depKey.clear(); + pend.Register(pm); + pend.RearmConfirm(0xA3ull, uint32(time(NULL))); + if (!pend.SetReserve(0xA3ull, 55u, "test:mut:cut")) + { + printf("ahmutresult FAIL: SetReserve\n"); + pass = false; + } + + uint64 const before = readMoney(); + + PlayerMutationResult res; + res.uuid = 0xA3ull; + res.op = uint8(IPC_PLAYER_CANCEL & 0xFFu); + res.status = uint8(MUT_REJECTED_STALE); + res.reason = 0; + res.facts = MutationFacts(); + res.facts.auctionId = 990001u; + AhHandlePlayerMutationResult(res); + + if (rowState("test:mut:cut") != 2u) { printf("ahmutresult FAIL: stale cut row not TERMINAL_BACK\n"); pass = false; @@ -2310,7 +3512,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"); @@ -2328,25 +3539,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 { @@ -2368,7 +3598,129 @@ static int RunAhResolveTest() return res ? res->Fetch()[0].GetUInt64() : 0; }; - // ---- RESOLVE_WON: bid + dep + item terminal; seller payout mail ---- + // 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(); CharacterDatabase.PExecute( @@ -2400,46 +3752,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; } } @@ -2452,13 +3805,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"); @@ -2471,7 +3833,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 @@ -2506,17 +3868,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"); @@ -2529,7 +3908,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; @@ -2561,6 +3940,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. @@ -2680,10 +4149,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) { @@ -2693,92 +4173,1597 @@ static int RunAhResolveTest() 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 -/// and mail the orphaned item_instance back to the seller, not merely -/// terminalize the custody rows. -static int RunAhRepairRecoveryTest() +/// 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 const ownerGuid = 1u; - uint32 const auctionId = 992001u; - uint64 const uuid = 0xABCD001ull; - uint64 const oldTime = static_cast(time(NULL)) > 7200u - ? static_cast(time(NULL)) - 7200u : 1u; - - CharacterDatabase.DirectPExecute( - "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); - CharacterDatabase.DirectPExecute( - "DELETE FROM `ah_worker_journal` WHERE `auction_id`=%u OR `uuid`=%llu", - auctionId, static_cast(uuid)); - CharacterDatabase.DirectPExecute( - "DELETE FROM `auction` WHERE `id`=%u", auctionId); - CharacterDatabase.DirectExecute( - "DELETE FROM `mail` WHERE `receiver`=1 AND `subject` LIKE '2589:%'"); - CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); - CharacterDatabase.DirectExecute( - "INSERT INTO `characters` (`guid`,`account`,`name`,`money`) " - "VALUES (1, 1, 'AhRepairRcv', 100000)"); - - sObjectMgr.SetHighestGuids(); - - uint32 itemId = 2589u; // Linen Cloth + uint32 itemId = 2589u; if (!ObjectMgr::GetItemPrototype(itemId)) { - std::unique_ptr r(WorldDatabase.PQuery( + std::unique_ptr result(WorldDatabase.Query( "SELECT `entry` FROM `item_template` " "WHERE `InventoryType`=0 AND `stackable`>1 ORDER BY `entry` LIMIT 1")); - if (r) + if (result) { - itemId = r->Fetch()[0].GetUInt32(); + itemId = result->Fetch()[0].GetUInt32(); } } if (!ObjectMgr::GetItemPrototype(itemId)) { - printf("ahrepair FAIL: no usable item prototype\n"); + printf("ahcustodyroute FAIL: no usable item prototype\n"); return 2; } - Item* item = Item::CreateItem(itemId, 1); - if (!item) - { - printf("ahrepair FAIL: CreateItem returned NULL\n"); - return 2; - } - item->SetOwnerGuid(ObjectGuid(HIGHGUID_PLAYER, ownerGuid)); - uint32 const itemGuid = item->GetGUIDLow(); + 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 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; + 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); + + 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 || + mailCount(sellerGuid, AUCTION_SUCCESSFUL) != 1 || + mailCount(bidderGuid, AUCTION_WON) != 1 || + itemMailCount(bidderGuid, itemGuid) != 1) + { + printf("ahcustodyroute FAIL: runtime-disable winning regression\n"); + pass = false; + } + } + } + + // 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]); + 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; +} +/// 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(); - item->SaveToDB(); + CustodyLedger::Insert(TestCustodyRow(0, "test:route-gate", CUSTODY_GOLD, + ROLE_BID, 9502u, 100u, 0u, 995190u)); if (!CharacterDatabase.CommitTransactionChecked()) { - delete item; - printf("ahrepair FAIL: item seed commit failed\n"); - return 2; + 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 +/// and mail the orphaned item_instance back to the seller, not merely +/// terminalize the custody rows. +static int RunAhRepairRecoveryTest() +{ + bool pass = true; + CharacterDatabase.AllowAsyncTransactions(); + + sObjectMgr.LoadItemPrototypes(); + + uint32 const ownerGuid = 1u; + uint32 const auctionId = 992001u; + uint64 const uuid = 0xABCD001ull; + uint64 const oldTime = static_cast(time(NULL)) > 7200u + ? static_cast(time(NULL)) - 7200u : 1u; + + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + CharacterDatabase.DirectPExecute( + "DELETE FROM `ah_worker_journal` WHERE `auction_id`=%u OR `uuid`=%llu", + auctionId, static_cast(uuid)); + CharacterDatabase.DirectPExecute( + "DELETE FROM `auction` WHERE `id`=%u", auctionId); + CharacterDatabase.DirectExecute( + "DELETE FROM `mail` WHERE `receiver`=1 AND `subject` LIKE '2589:%'"); + CharacterDatabase.DirectExecute("DELETE FROM `characters` WHERE `guid`=1"); + CharacterDatabase.DirectExecute( + "INSERT INTO `characters` (`guid`,`account`,`name`,`money`) " + "VALUES (1, 1, 'AhRepairRcv', 100000)"); + + sObjectMgr.SetHighestGuids(); + + uint32 itemId = 2589u; // Linen Cloth + if (!ObjectMgr::GetItemPrototype(itemId)) + { + std::unique_ptr r(WorldDatabase.PQuery( + "SELECT `entry` FROM `item_template` " + "WHERE `InventoryType`=0 AND `stackable`>1 ORDER BY `entry` LIMIT 1")); + if (r) + { + itemId = r->Fetch()[0].GetUInt32(); + } + } + if (!ObjectMgr::GetItemPrototype(itemId)) + { + printf("ahrepair FAIL: no usable item prototype\n"); + return 2; + } + + Item* item = Item::CreateItem(itemId, 1); + if (!item) + { + printf("ahrepair FAIL: CreateItem returned NULL\n"); + return 2; + } + item->SetOwnerGuid(ObjectGuid(HIGHGUID_PLAYER, ownerGuid)); + uint32 const itemGuid = item->GetGUIDLow(); + + CharacterDatabase.BeginTransaction(); + item->SaveToDB(); + if (!CharacterDatabase.CommitTransactionChecked()) + { + delete item; + printf("ahrepair FAIL: item seed commit failed\n"); + return 2; + } + delete item; + + PlayerMutationResult journalRes; + journalRes.uuid = uuid; + journalRes.op = uint8(IPC_PLAYER_CANCEL & 0xFFu); + journalRes.status = uint8(MUT_PREPARED); + journalRes.reason = 0; + journalRes.facts = MutationFacts(); + journalRes.facts.auctionId = auctionId; + journalRes.facts.houseId = 7; + journalRes.facts.itemGuid = itemGuid; + journalRes.facts.itemTemplate = itemId; + journalRes.facts.randomPropertyId = 0; + journalRes.facts.sellerGuid = ownerGuid; + journalRes.facts.deposit = 32u; + + 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 ('dep:%u',0,0,0,%u,0,32,0,%u," UI64FMTD ",0)," + " ('item:%u',1,3,0,%u,0,0,%u,%u," UI64FMTD ",0)", + auctionId, ownerGuid, auctionId, oldTime, + auctionId, ownerGuid, itemGuid, auctionId, oldTime); + CharacterDatabase.PExecute( + "INSERT INTO `ah_worker_journal` " + "(`uuid`,`auction_id`,`kind`,`state`,`facts`,`created_time`,`resolved_time`) " + "VALUES (%llu,%u,%u,1,'%s'," UI64FMTD "," UI64FMTD ")", + static_cast(uuid), auctionId, + uint32(IPC_PLAYER_CANCEL & 0xFFu), factsHex.c_str(), oldTime, oldTime); + if (!CharacterDatabase.CommitTransactionChecked()) + { + printf("ahrepair FAIL: recovery seed commit failed\n"); + pass = false; + } + + 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 command summary mismatch\n"); + pass = false; + } + if (CountCliChunks(repairCapture, "replayed committed cancel") != 1u) + { + printf("ahrepair FAIL: repeated auction rows replayed journal more than once\n"); + pass = false; + } + + 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; + }; + + std::string const depKey = "dep:" + std::to_string(auctionId); + std::string const itemKey = "item:" + std::to_string(auctionId); + if (rowState(depKey.c_str()) != CST_TERMINAL_OK) + { + printf("ahrepair FAIL: deposit not TERMINAL_OK\n"); + pass = false; + } + if (rowState(itemKey.c_str()) != CST_TERMINAL_OK) + { + printf("ahrepair FAIL: item custody not TERMINAL_OK\n"); + pass = false; + } + + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `mail_items` WHERE `receiver`=%u AND `item_guid`=%u", + ownerGuid, itemGuid)); + if (!res || res->Fetch()[0].GetUInt64() != 1u) + { + printf("ahrepair FAIL: returned item mail missing\n"); + pass = false; + } + } + + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT `checked` FROM `mail` m " + "JOIN `mail_items` mi ON mi.`mail_id`=m.`id` " + "WHERE mi.`receiver`=%u AND mi.`item_guid`=%u", + ownerGuid, itemGuid)); + if (!res || !(res->Fetch()[0].GetUInt32() & MAIL_CHECK_MASK_COPIED)) + { + printf("ahrepair FAIL: returned item mail missing copied mask\n"); + pass = false; + } + } + + { + std::unique_ptr res(CharacterDatabase.PQuery( + "SELECT COUNT(*) FROM `character_inventory` WHERE `item`=%u", itemGuid)); + if (res && res->Fetch()[0].GetUInt64() != 0u) + { + printf("ahrepair FAIL: item should not be placed directly in inventory\n"); + pass = false; + } + } + + // 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( + "DELETE FROM `item_instance` WHERE `guid`=%u", itemGuid); + CharacterDatabase.DirectPExecute( + "DELETE FROM `mail` WHERE `receiver`=%u AND `subject` LIKE '%u:%%'", + ownerGuid, itemId); + CharacterDatabase.DirectPExecute( + "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + 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("ahrepair OK\n"); + return 0; + } + 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); + } + + // ---- 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) " + "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; } - delete item; + return 2; +} - PlayerMutationResult journalRes; - journalRes.uuid = uuid; - journalRes.op = uint8(IPC_PLAYER_CANCEL & 0xFFu); - journalRes.status = uint8(MUT_PREPARED); - journalRes.reason = 0; - journalRes.facts = MutationFacts(); - journalRes.facts.auctionId = auctionId; - journalRes.facts.houseId = 7; - journalRes.facts.itemGuid = itemGuid; - journalRes.facts.itemTemplate = itemId; - journalRes.facts.randomPropertyId = 0; - journalRes.facts.sellerGuid = ownerGuid; - journalRes.facts.deposit = 32u; +/// 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; - journalRes.Encode(bb); + prepared.Encode(bb); std::string const factsHex = TestHexEncode(bb); CharacterDatabase.BeginTransaction(); @@ -2786,97 +5771,88 @@ static int RunAhRepairRecoveryTest() "INSERT INTO `custody_ledger` " "(`idem_key`,`kind`,`role`,`state`,`owner_guid`,`beneficiary_guid`," "`amount`,`item_guid`,`auction_id`,`created_time`,`resolved_time`) " - "VALUES ('dep:%u',0,0,0,%u,0,32,0,%u," UI64FMTD ",0)," - " ('item:%u',1,3,0,%u,0,0,%u,%u," UI64FMTD ",0)", - auctionId, ownerGuid, auctionId, oldTime, - auctionId, ownerGuid, itemGuid, auctionId, oldTime); + "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,1,'%s'," UI64FMTD "," UI64FMTD ")", + "(`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(), oldTime, oldTime); + uint32(IPC_PLAYER_CANCEL & 0xFFu), factsHex.c_str()); if (!CharacterDatabase.CommitTransactionChecked()) { - printf("ahrepair FAIL: recovery seed commit failed\n"); - pass = false; + printf("ahreconcileabort FAIL: seed commit\n"); + return 2; } - uint32 repairedRows = 0u; - if (!AhRepairCommittedCancelAuction(auctionId, repairedRows)) - { - printf("ahrepair FAIL: committed cancel repair returned false\n"); - pass = false; - } - if (repairedRows != 2u) - { - printf("ahrepair FAIL: committed cancel repairedRows=%u\n", repairedRows); - pass = false; - } + 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 rowState = [](char const* key) -> uint32 + auto readMoney = []() -> uint64 { std::unique_ptr res(CharacterDatabase.PQuery( - "SELECT `state` FROM `custody_ledger` WHERE `idem_key`='%s'", key)); + "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; }; - std::string const depKey = "dep:" + std::to_string(auctionId); - std::string const itemKey = "item:" + std::to_string(auctionId); - if (rowState(depKey.c_str()) != CST_TERMINAL_OK) + uint64 const before = readMoney(); + std::string originalConfig; + std::string testConfig; + if (!TestArmCustodyCommitFailure( + "reconcile-abort-release", originalConfig, testConfig)) { - printf("ahrepair FAIL: deposit not TERMINAL_OK\n"); - pass = false; - } - if (rowState(itemKey.c_str()) != CST_TERMINAL_OK) - { - printf("ahrepair FAIL: item custody not TERMINAL_OK\n"); - pass = false; + TestRestoreConfig(originalConfig, testConfig); + printf("ahreconcileabort FAIL: could not arm commit failure\n"); + return 2; } - + AhReconcileOnReconnect(); + if (!TestRestoreConfig(originalConfig, testConfig)) { - std::unique_ptr res(CharacterDatabase.PQuery( - "SELECT COUNT(*) FROM `mail_items` WHERE `receiver`=%u AND `item_guid`=%u", - ownerGuid, itemGuid)); - if (!res || res->Fetch()[0].GetUInt64() != 1u) - { - printf("ahrepair FAIL: returned item mail missing\n"); - pass = false; - } + 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) { - std::unique_ptr res(CharacterDatabase.PQuery( - "SELECT `checked` FROM `mail` m " - "JOIN `mail_items` mi ON mi.`mail_id`=m.`id` " - "WHERE mi.`receiver`=%u AND mi.`item_guid`=%u", - ownerGuid, itemGuid)); - if (!res || !(res->Fetch()[0].GetUInt32() & MAIL_CHECK_MASK_COPIED)) - { - printf("ahrepair FAIL: returned item mail missing copied mask\n"); - pass = false; - } + 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) { - std::unique_ptr res(CharacterDatabase.PQuery( - "SELECT COUNT(*) FROM `character_inventory` WHERE `item`=%u", itemGuid)); - if (res && res->Fetch()[0].GetUInt64() != 0u) - { - printf("ahrepair FAIL: item should not be placed directly in inventory\n"); - pass = false; - } + printf("ahreconcileabort FAIL: retained cut release did not apply " + "once on retry\n"); + pass = false; } + pend.Take(uuid, held); CharacterDatabase.DirectPExecute( - "DELETE FROM `mail_items` WHERE `item_guid`=%u", itemGuid); - CharacterDatabase.DirectPExecute( - "DELETE FROM `item_instance` WHERE `guid`=%u", itemGuid); - CharacterDatabase.DirectPExecute( - "DELETE FROM `mail` WHERE `receiver`=%u AND `subject` LIKE '%u:%%'", - ownerGuid, itemId); - CharacterDatabase.DirectPExecute( - "DELETE FROM `custody_ledger` WHERE `auction_id`=%u", auctionId); + "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)); @@ -2884,12 +5860,194 @@ static int RunAhRepairRecoveryTest() if (pass) { - printf("ahrepair OK\n"); + printf("ahreconcileabort OK\n"); return 0; } 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); + 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'", + 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 (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); + 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 @@ -2929,6 +6087,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); @@ -3099,7 +6260,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 @@ -3124,7 +6363,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)); @@ -3155,10 +6395,132 @@ static int RunAhMaterializeTest() } } - // Clean up (Part-1 minted item survives the sweep; drop it + fixtures). + // ---- Part 5: 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; + } + } + + // ---- 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); + } + + // ---- 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 " "('%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); @@ -3247,11 +6609,35 @@ int RunMangosdTest(std::string const& name) return RunAhRepairRecoveryTest(); } + if (name == "ahcustodyroute") + { + return RunAhCustodyRouteTest(); + } + if (name == "ahroutegate") + { + return RunAhRouteGateTest(); + } + + if (name == "ahreconcile") + { + return RunAhReconcileTest(); + } + + if (name == "ahreconcileabort") + { + return RunAhReconcileAbortTest(); + } + if (name == "ahmaterialize") { return RunAhMaterializeTest(); } + if (name == "ahbotterminal") + { + return RunAhBotTerminalTest(); + } + printf("%s FAIL: unknown test\n", name.c_str()); return 2; } 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 1a6d08029..bb1051341 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,29 @@ void Master::ShutdownWorld() sMapMgr.UnloadAll(); } -int Master::Run() +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; } + if (!testMode.empty()) + { + int const rc = RunMangosdTest(testMode); + sLog.outString("mangosd test '%s' exit %d", testMode.c_str(), rc); + sLog.Flush(); + fflush(stdout); + std::_Exit(rc); + } + ClearOnlineAccounts(); if (!warden::WardenCheckCatalogLoader().LoadAndPublish()) diff --git a/src/mangosd/Master.h b/src/mangosd/Master.h index b728b017e..321700209 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,8 @@ class Master * * @return The process exit code. */ - int Run(); + int Run(std::string const& testMode = "", + bool allowDestructiveTests = false); private: 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) # diff --git a/src/mangosd/mangosd.cpp b/src/mangosd/mangosd.cpp index 56da7dc68..d33d7a745 100644 --- a/src/mangosd/mangosd.cpp +++ b/src/mangosd/mangosd.cpp @@ -161,6 +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 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" @@ -204,6 +206,8 @@ int main(int argc, char** argv) char const* cfg_file = MANGOSD_CONFIG_LOCATION; 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. @@ -225,6 +229,14 @@ int main(int argc, char** argv) { sAuctionBotConfig.SetConfigFileName(argv[++i]); } + else if (arg == "-t" && hasValue) + { + testMode = argv[++i]; + } + else if (arg == "--allow-destructive-tests") + { + allowDestructiveTests = true; + } else if (arg == "-s" && hasValue) { const std::string mode = argv[++i]; @@ -252,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) { @@ -376,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(); + const int runCode = master.Run(testMode, allowDestructiveTests); ///- Remove signal handling before leaving unhook_signals(); 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