From 8a13c5caaca0ecc076decc03e164bd82a80e576c Mon Sep 17 00:00:00 2001 From: Tecc Date: Wed, 12 Aug 2026 21:39:00 +0000 Subject: [PATCH 1/3] fix(buyer): stop skip-starvation, add cap headroom, escalate own bids to buyout Three stacked causes kept player auctions from ever being bought: - Candidates were scanned lowest-auction-ID-first and every skipped auction consumed the BidsPerInterval budget while re-entering the query each cycle, so a handful of permanently-overpriced auctions starved everything newer. Candidates are now shuffled and only successful operations (bid or buyout) consume the budget. - Overrides with minPrice == avgPrice collapse the buy cap (avg + (avg - min)) to exactly avg while the seller lists at avg +-10%, making ordinary undercuts unbuyable. The cap now gets at least 15% headroom above avg whenever an override exists. - Auctions the bot already led were excluded via buyguid, so a single bid froze them until expiry. They stay in the candidate set; the bot never outbids itself but escalates to a buyout when it fits the cap. Closes #28 --- src/AuctionHouseBot.cpp | 59 ++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/AuctionHouseBot.cpp b/src/AuctionHouseBot.cpp index 21a2bc4..1e80b3f 100644 --- a/src/AuctionHouseBot.cpp +++ b/src/AuctionHouseBot.cpp @@ -262,7 +262,8 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se return; } - // Retrieve items not owned by the bot and not bought/bidded on by the bot + // Retrieve items not owned by the bot. Auctions the bot already leads stay + // included so it can still escalate to a buyout instead of abandoning them. std::string botGUIDsStr = JoinGUIDs(config->GetBotGUIDs()); uint32 auctionHouseID = config->GetAHID(); @@ -271,7 +272,7 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se LOG_INFO("module", "AHBot [{}]: Querying auction house {} for items not owned by bots", _id, auctionHouseID); } - QueryResult ahContentQueryResult = CharacterDatabase.Query("SELECT id FROM auctionhouse WHERE houseid = {} AND itemowner NOT IN ({}) AND buyguid NOT IN ({})", auctionHouseID, botGUIDsStr, botGUIDsStr); + QueryResult ahContentQueryResult = CharacterDatabase.Query("SELECT id FROM auctionhouse WHERE houseid = {} AND itemowner NOT IN ({})", auctionHouseID, botGUIDsStr); if (!ahContentQueryResult || ahContentQueryResult->GetRowCount() == 0) { @@ -280,14 +281,19 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se // Fetches content of selected AH to look for possible bids AuctionHouseObject* auctionHouseObject = sAuctionMgr->GetAuctionsMap(config->GetAHFID()); - std::set auctionsGuidsToConsider; + std::vector auctionsGuidsToConsider; do { uint32 auctionGuid = ahContentQueryResult->Fetch()->Get(); - auctionsGuidsToConsider.insert(auctionGuid); + auctionsGuidsToConsider.push_back(auctionGuid); } while (ahContentQueryResult->NextRow()); + // Randomized order: a fixed lowest-ID-first scan lets permanently + // overpriced auctions occupy the head of the queue and starve newer + // listings out of consideration entirely. + std::shuffle(auctionsGuidsToConsider.begin(), auctionsGuidsToConsider.end(), std::mt19937(std::random_device()())); + if (config->DebugOutBuyer) { LOG_INFO("module", "AHBot [{}]: Found {} possible bids", _id, auctionsGuidsToConsider.size()); @@ -323,22 +329,19 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se LOG_INFO("module", "AHBot [{}]: Considering {} auctions per interval to bid on.", _id, bidsPerInterval); } - for (uint32 count = 1; count <= bidsPerInterval && !auctionsGuidsToConsider.empty(); ++count) + // Only successful operations (bid or buyout) consume the per-interval + // budget; skipped auctions used to eat it and could starve the whole run. + uint32 opsDone = 0; + + for (uint32 auctionID : auctionsGuidsToConsider) { - if (auctionsGuidsToConsider.empty()) { - return; + if (opsDone >= bidsPerInterval) + { + break; } - std::set::iterator it = auctionsGuidsToConsider.begin(); - std::advance(it, 0); - uint32 auctionID = *it; AuctionEntry* auction = auctionHouseObject->GetAuction(auctionID); - // - // Prevent to bid again on the same auction - // - auctionsGuidsToConsider.erase(it); - if (!auction) { if (config->DebugOutBuyer) @@ -388,6 +391,16 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se } uint64 maxPrice = (avgPrice + ( avgPrice - minPrice )); + + // A degenerate override (minPrice == avgPrice) collapses the cap to the + // exact average while the seller lists at avg +-10%, so ordinary player + // undercuts of the bot's own listings would never be bought. Guarantee + // some headroom above the average. + if (avgPrice > 0 && maxPrice < avgPrice * 115 / 100) + { + maxPrice = avgPrice * 115 / 100; + } + uint64 SellPriceValue = maxPrice > 0 ? maxPrice : prototype->SellPrice; uint64 BuyPriceValue = avgPrice > 0 ? avgPrice : prototype->BuyPrice; @@ -504,6 +517,16 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se continue; } + // If the bot already leads this auction it must never outbid itself; + // the only remaining move is escalating to a buyout when that still + // fits the cap, otherwise the auction just runs out at the current bid. + bool botAlreadyLeads = auction->bidder && gBotsId.find(auction->bidder.GetCounter()) != gBotsId.end(); + + if (botAlreadyLeads && (auction->buyout == 0 || auction->buyout > maximumBid)) + { + continue; + } + // Calculate our bid: step up from the current price by a small percentage, // rather than leaping anywhere up to our maximum acceptable price. double bidValue = currentPrice + (static_cast(currentPrice) * urand(config->GetBuyerBidIncrementMinPct(), config->GetBuyerBidIncrementMaxPct()) / 100.0); @@ -539,7 +562,7 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se // Check whether we do normal bid, or buyout // - if ((bidPrice < auction->buyout) || (auction->buyout == 0)) + if (!botAlreadyLeads && ((bidPrice < auction->buyout) || (auction->buyout == 0))) { // // Perform a new bid on the auction @@ -571,6 +594,8 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se { LOG_INFO("module", "AHBot [{}]: New bid, itemid={}, ah={}, auctionId={} item={}, start={}, current={}, buyout={}", _id, prototype->ItemId, auction->GetHouseId(), auction->Id, auction->item_template, auction->startbid, currentPrice, auction->buyout); } + + opsDone++; } else { @@ -608,6 +633,8 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se { LOG_INFO("module", "AHBot [{}]: Bought , itemid={}, ah={}, item={}, start={}, current={}, buyout={}", _id, prototype->ItemId, AuctionHouseId(auction->GetHouseId()), auction->item_template, auction->startbid, currentPrice, auction->buyout); } + + opsDone++; } } From 2e863c72e4a874e9eb8b53b96b3fe3d69ed8d497 Mon Sep 17 00:00:00 2001 From: Tecc Date: Wed, 12 Aug 2026 22:18:15 +0000 Subject: [PATCH 2/3] fix(buyer): bound evaluations per run Skips no longer consume the bid budget, so a skip-heavy cycle could scan every candidate. Cap evaluations at 100x BidsPerInterval; the shuffled order keeps the sample fair across cycles. --- src/AuctionHouseBot.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/AuctionHouseBot.cpp b/src/AuctionHouseBot.cpp index 1e80b3f..e6a819b 100644 --- a/src/AuctionHouseBot.cpp +++ b/src/AuctionHouseBot.cpp @@ -331,15 +331,22 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se // Only successful operations (bid or buyout) consume the per-interval // budget; skipped auctions used to eat it and could starve the whole run. + // Evaluations are still bounded so an AH with a huge number of player + // auctions cannot make a skip-heavy cycle scan all of them; the shuffle + // keeps the sample fair across cycles, so this reintroduces no starvation. uint32 opsDone = 0; + uint32 evaluated = 0; + uint32 const maxEvaluations = bidsPerInterval * 100; for (uint32 auctionID : auctionsGuidsToConsider) { - if (opsDone >= bidsPerInterval) + if (opsDone >= bidsPerInterval || evaluated >= maxEvaluations) { break; } + evaluated++; + AuctionEntry* auction = auctionHouseObject->GetAuction(auctionID); if (!auction) From c7d09ec99bd448af04975ebb96fdf13d75880071 Mon Sep 17 00:00:00 2001 From: Tecc Date: Wed, 12 Aug 2026 22:30:35 +0000 Subject: [PATCH 3/3] fix(buyer): build the SQL exclusion list from gBotsId In account-only mode the config GUID list is empty, so JoinGUIDs rendered an invalid NOT IN () clause and broke the buyer query. Use the resolved bot character set and bail out when it is empty. --- src/AuctionHouseBot.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/AuctionHouseBot.cpp b/src/AuctionHouseBot.cpp index e6a819b..4f729ca 100644 --- a/src/AuctionHouseBot.cpp +++ b/src/AuctionHouseBot.cpp @@ -264,7 +264,15 @@ void AuctionHouseBot::Buy(Player* AHBplayer, AHBConfig* config, WorldSession* se // Retrieve items not owned by the bot. Auctions the bot already leads stay // included so it can still escalate to a buyout instead of abandoning them. - std::string botGUIDsStr = JoinGUIDs(config->GetBotGUIDs()); + // The exclusion list comes from the resolved bot characters (gBotsId), not + // the raw config GUID list: in account-only mode the latter is empty and + // would render an invalid "NOT IN ()" clause. + if (gBotsId.empty()) + { + return; + } + + std::string botGUIDsStr = JoinGUIDs(std::vector(gBotsId.begin(), gBotsId.end())); uint32 auctionHouseID = config->GetAHID(); if (config->DebugOutBuyer)