diff --git a/conf/mod_ahbot.conf.dist b/conf/mod_ahbot.conf.dist index 37fca9b..f79faca 100644 --- a/conf/mod_ahbot.conf.dist +++ b/conf/mod_ahbot.conf.dist @@ -162,6 +162,46 @@ AuctionHouseBot.MinPriceTolerance = 0.95 AuctionHouseBot.Buyer.BidIncrementMinPct = 5 AuctionHouseBot.Buyer.BidIncrementMaxPct = 15 +############################################################################### +# AUCTION HOUSE BOT DYNAMIC PRICING (demand multiplier) +# +# AuctionHouseBot.DynamicPricing.Enable +# Enable/Disable a per-item price multiplier that reacts to real human +# purchases (never bot or playerbot purchases) and decays back to neutral +# (1.0) over time. Applied on top of the priceOverride baseline. +# Default 0 (disabled) +# +# AuctionHouseBot.DynamicPricing.BumpPercent +# How much a human purchase bumps the item's multiplier, in percent. +# Default 8 +# +# AuctionHouseBot.DynamicPricing.DecayHalfLifeHours +# Hours for a bump to decay halfway back to neutral (1.0). +# Default 72 +# +# AuctionHouseBot.DynamicPricing.MinMultiplier +# Lower clamp for the multiplier. +# Default 0.5 +# +# AuctionHouseBot.DynamicPricing.MaxMultiplier +# Upper clamp for the multiplier. +# Default 3.0 +# +# AuctionHouseBot.DynamicPricing.BotAccountPrefixes +# Comma-separated, case-insensitive account username prefixes treated as +# bots (excluded from the demand signal) even though they aren't in +# gBotsId. Matches mod-playerbots' random-bot account naming. +# Default rndbot +# +############################################################################### + +AuctionHouseBot.DynamicPricing.Enable = 0 +AuctionHouseBot.DynamicPricing.BumpPercent = 8 +AuctionHouseBot.DynamicPricing.DecayHalfLifeHours = 72 +AuctionHouseBot.DynamicPricing.MinMultiplier = 0.5 +AuctionHouseBot.DynamicPricing.MaxMultiplier = 3.0 +AuctionHouseBot.DynamicPricing.BotAccountPrefixes = rndbot + ############################################################################### # AUCTION HOUSE BOT FILTERS PART 1 # diff --git a/data/sql/db-world/2026_07_02_00_mod_auctionhousebot_demand.sql b/data/sql/db-world/2026_07_02_00_mod_auctionhousebot_demand.sql new file mode 100644 index 0000000..d97c165 --- /dev/null +++ b/data/sql/db-world/2026_07_02_00_mod_auctionhousebot_demand.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for mod_auctionhousebot_demand +-- ---------------------------- +DROP TABLE IF EXISTS `mod_auctionhousebot_demand`; +CREATE TABLE `mod_auctionhousebot_demand` ( + `item` int(10) UNSIGNED NOT NULL, + `multiplier` double NOT NULL DEFAULT 1.0, + `last_bump` bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (`item`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; diff --git a/docs/superpowers/specs/2026-07-02-demand-multiplier-design.md b/docs/superpowers/specs/2026-07-02-demand-multiplier-design.md new file mode 100644 index 0000000..5e6ecf9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-demand-multiplier-design.md @@ -0,0 +1,106 @@ +# Player-reactive demand multiplier — design + +Date: 2026-07-02 +Status: Approved, implementing. + +## Problem + +Prices are anchored to a curated ChromieCraft baseline (`mod_auctionhousebot_priceOverride`), +which is static until the backfill tool is rerun by hand. On a low-population realm nothing in +the pricing path reacts to what players actually buy, so a baseline that's slightly off for a +given item stays off indefinitely, and there's no way for a run on an item to nudge its price up +the way a real market would. + +## Signal + +`AHBot_AuctionHouseScript::OnAuctionSuccessful` already fires for every completed auction and +already writes a row to `mod_auctionhousebot_auction_history`. When dynamic pricing is enabled +and the winning bidder is a real human (not one of the bot's own `gBotsId` characters, not a +mod-playerbots random bot account), bump that item's stored multiplier: + +``` +m_new = clamp(m_effective * (1 + BumpPercent/100), MinMultiplier, MaxMultiplier) +``` + +`m_effective` is the current decayed value (see below), so repeated bumps compound instead of +resetting the decay clock without capturing the earlier bump. + +### Human detection + +- Bot characters: buyer character GUID is checked against `gBotsId`, the module's own set of + auctioning character GUIDs (already used the same way in `OnAuctionAdd`/`OnAuctionRemove`). +- Playerbots: there's no compile-time dependency on mod-playerbots, so bots are detected by + account instead. The buyer's account id is resolved via `sCharacterCache`, then the account + `username` (LoginDatabase) is checked against a configurable, case-insensitive prefix list + (default `rndbot`, mod-playerbots' random-bot account prefix). +- Account id -> is-human verdicts are cached in memory (`gAccountHumanCache`) to avoid a + LoginDatabase round trip on every trade; the world thread is single-threaded for all AHBot + hooks so the cache needs no locking (same assumption the existing override maps already make). +- If GUID or account resolution is ambiguous or fails, the code does not bump — fail toward + no-op, never toward a crash or a wrong guess. + +## Decay + +Lazy, computed on read, no periodic job and no background rewrite. Only a bump rewrites the +stored row. + +``` +effective = 1.0 + (stored - 1.0) * pow(0.5, hoursSince(last_bump) / DecayHalfLifeHours) +``` + +Snapped to exactly `1.0` once `|effective - 1.0| < 0.01`, so an old, fully-decayed row reads as +neutral instead of asymptotically approaching it forever. + +## Table schema + +`mod_auctionhousebot_demand`: + +| column | type | notes | +|---|---|---| +| `item` | `INT UNSIGNED` | primary key | +| `multiplier` | `DOUBLE NOT NULL DEFAULT 1.0` | stored (pre-decay) value | +| `last_bump` | `BIGINT NOT NULL DEFAULT 0` | unix timestamp of the last bump | + +Loaded into an in-memory `unordered_map` on startup and on `.reload config`, +alongside the existing price/count override maps (`AHBot_WorldScript::LoadSharedOverrides`). + +## Application + +`AuctionHouseBot::AdjustPrices` is the single choke point that finalizes an item's buyout/bid +before the seller applies its +-10% listing deviation. When dynamic pricing is enabled, both +`buyoutPrice` and `bidPrice` are multiplied by the item's effective demand multiplier there, +after the existing moving-average and override clamp. The existing bid<=buyout enforcement in +`Sell()` runs after `AdjustPrices` returns and after the deviation is applied, so it still +catches any inversion the multiplier could introduce. + +## Config + +`conf/mod_ahbot.conf.dist`: + +- `AuctionHouseBot.DynamicPricing.Enable` (default 0) +- `AuctionHouseBot.DynamicPricing.BumpPercent` (default 8) +- `AuctionHouseBot.DynamicPricing.DecayHalfLifeHours` (default 72) +- `AuctionHouseBot.DynamicPricing.MinMultiplier` (default 0.5) +- `AuctionHouseBot.DynamicPricing.MaxMultiplier` (default 3.0) +- `AuctionHouseBot.DynamicPricing.BotAccountPrefixes` (default `rndbot`, comma-separated) + +Bounds are validated on load (`Min <= 1 <= Max`, `BumpPercent > 0`, `HalfLife > 0`); nonsense +values are clamped to a sane default and logged with `LOG_WARN`, never a crash. + +## Explicitly out of scope + +- Supply-side signal: this feature only reacts to human *purchases*. A human *listing* or + undercutting an item is not fed back into the multiplier. Revisit only if the buy-side signal + proves insufficient on its own. +- Per-faction demand: the multiplier is per-item, not per-auction-house. A bump observed on one + faction's AH is written to that config's in-memory map immediately and reaches the other two + configs on the next full reload (same propagation model the existing price/count overrides + use), not instantly. + +## Testing + +C++ can't be unit-tested locally (needs the full core). Gates: +- CI `-Werror` build (compile + no unused-param/var). +- TEST-realm validation: manually buy an item as a real character, confirm the row in + `mod_auctionhousebot_demand` appears/updates, confirm the bot's next listing for that item + reflects the bumped price, confirm playerbot purchases do not create/bump a row. diff --git a/src/AuctionHouseBot.cpp b/src/AuctionHouseBot.cpp index 72f3f06..21a2bc4 100644 --- a/src/AuctionHouseBot.cpp +++ b/src/AuctionHouseBot.cpp @@ -2000,6 +2000,14 @@ void AuctionHouseBot::AdjustPrices(uint32 itemId, uint64& buyoutPrice, uint64& b buyoutPrice = std::clamp(buyoutPrice, adjustedMinPrice, maxPrice); bidPrice = std::clamp(bidPrice, adjustedMinPrice, maxPrice); } + + // Demand multiplier: reacts to real human purchases, decays back to neutral over time. + if (config->DynamicPricingEnable) + { + double demandMultiplier = config->GetEffectiveDemandMultiplier(itemId); + buyoutPrice = uint64(buyoutPrice * demandMultiplier); + bidPrice = uint64(bidPrice * demandMultiplier); + } } void AuctionHouseBot::CleanupOldAuctionHistory() diff --git a/src/AuctionHouseBotAuctionHouseScript.cpp b/src/AuctionHouseBotAuctionHouseScript.cpp index 35670fa..5816818 100644 --- a/src/AuctionHouseBotAuctionHouseScript.cpp +++ b/src/AuctionHouseBotAuctionHouseScript.cpp @@ -3,12 +3,74 @@ */ #include "AuctionHouseMgr.h" +#include "CharacterCache.h" +#include "Field.h" #include "GameTime.h" +#include "QueryResult.h" #include "AuctionHouseBot.h" #include "AuctionHouseBotCommon.h" #include "AuctionHouseBotAuctionHouseScript.h" +#include + +// Demand multiplier human detection: a buyer counts as human when their character isn't +// one of the bot's own gBotsId characters and their account isn't a mod-playerbots random +// bot (there's no compile-time dependency on mod-playerbots, so this is done by account +// username prefix instead). Resolution failures return false ("don't bump") rather than +// guessing. +static bool IsHumanBuyer(ObjectGuid buyerGuid, AHBConfig* config) +{ + if (buyerGuid.IsEmpty()) + { + return false; + } + + if (gBotsId.find(buyerGuid.GetCounter()) != gBotsId.end()) + { + return false; + } + + uint32 accountId = sCharacterCache->GetCharacterAccountIdByGuid(buyerGuid); + + if (accountId == 0) + { + return false; + } + + auto cacheIt = gAccountHumanCache.find(accountId); + if (cacheIt != gAccountHumanCache.end()) + { + return cacheIt->second; + } + + QueryResult result = LoginDatabase.Query("SELECT username FROM account WHERE id = {}", accountId); + + if (!result) + { + // Ambiguous resolution: don't cache a guess, just don't bump this time. + return false; + } + + std::string username = result->Fetch()[0].Get(); + std::transform(username.begin(), username.end(), username.begin(), ::tolower); + + bool isHuman = true; + + for (const std::string& prefix : config->DynamicPricingBotAccountPrefixes) + { + if (!prefix.empty() && username.compare(0, prefix.size(), prefix) == 0) + { + isHuman = false; + break; + } + } + + gAccountHumanCache[accountId] = isHuman; + + return isHuman; +} + AHBot_AuctionHouseScript::AHBot_AuctionHouseScript() : AuctionHouseScript("AHBot_AuctionHouseScript", { AUCTIONHOUSEHOOK_ON_BEFORE_AUCTIONHOUSEMGR_SEND_AUCTION_SUCCESSFUL_MAIL, AUCTIONHOUSEHOOK_ON_BEFORE_AUCTIONHOUSEMGR_SEND_AUCTION_EXPIRED_MAIL, @@ -233,6 +295,12 @@ void AHBot_AuctionHouseScript::OnAuctionSuccessful(AuctionHouseObject* /*ah*/, A config->UpdateItemStats(auction->item_template, auction->itemCount, auction->buyout); + // Demand multiplier: only real human purchases nudge the price up. + if (config->DynamicPricingEnable && IsHumanBuyer(auction->bidder, config)) + { + config->BumpDemand(auction->item_template); + } + // Insert record into auction history table std::string auctionType = (auction->bid > 0) ? "bid" : "buyout"; uint64 finalPrice = (auction->bid > 0) ? auction->bid : auction->buyout; diff --git a/src/AuctionHouseBotCommon.cpp b/src/AuctionHouseBotCommon.cpp index 640c775..ce62fe1 100644 --- a/src/AuctionHouseBotCommon.cpp +++ b/src/AuctionHouseBotCommon.cpp @@ -20,3 +20,5 @@ AHBConfig* gNeutralConfig = new AHBConfig(7); std::set gBotsId; std::set gBots; + +std::unordered_map gAccountHumanCache; diff --git a/src/AuctionHouseBotCommon.h b/src/AuctionHouseBotCommon.h index 26b477c..73532ad 100644 --- a/src/AuctionHouseBotCommon.h +++ b/src/AuctionHouseBotCommon.h @@ -21,6 +21,7 @@ #define AUCTION_HOUSE_BOT_COMMON_H #include +#include #include "Common.h" @@ -104,4 +105,8 @@ enum class AHBotCommand : uint32 extern std::set gBotsId; // Active bots players ids extern std::set gBots; // Active bots +// Demand multiplier: account id -> "is a real human" verdict, cached to avoid a +// LoginDatabase round trip on every completed auction. World thread only, no locking. +extern std::unordered_map gAccountHumanCache; + #endif // AUCTION_HOUSE_BOT_COMMON_H diff --git a/src/AuctionHouseBotConfig.cpp b/src/AuctionHouseBotConfig.cpp index 140996f..480f8cc 100644 --- a/src/AuctionHouseBotConfig.cpp +++ b/src/AuctionHouseBotConfig.cpp @@ -21,6 +21,7 @@ #include "Common.h" #include "Config.h" #include "DatabaseEnv.h" +#include "GameTime.h" #include "Item.h" #include "ItemTemplate.h" #include "Log.h" @@ -31,6 +32,9 @@ #include "AuctionHouseBotCommon.h" #include "AuctionHouseBotConfig.h" +#include +#include + using namespace std; AHBConfig::AHBConfig() @@ -2100,6 +2104,45 @@ void AHBConfig::InitializeFromFile() BuyerBidIncrementMaxPct = 15; } + // Demand multiplier (reacts to real human purchases, decays back to neutral over time) + DynamicPricingEnable = sConfigMgr->GetOption ("AuctionHouseBot.DynamicPricing.Enable" , false); + DynamicPricingBumpPercent = sConfigMgr->GetOption("AuctionHouseBot.DynamicPricing.BumpPercent" , 8); + DynamicPricingDecayHalfLifeHours = sConfigMgr->GetOption("AuctionHouseBot.DynamicPricing.DecayHalfLifeHours", 72); + DynamicPricingMinMultiplier = sConfigMgr->GetOption ("AuctionHouseBot.DynamicPricing.MinMultiplier" , 0.5f); + DynamicPricingMaxMultiplier = sConfigMgr->GetOption ("AuctionHouseBot.DynamicPricing.MaxMultiplier" , 3.0f); + DynamicPricingBotAccountPrefixes = getCommaSeparatedStrings(sConfigMgr->GetOption("AuctionHouseBot.DynamicPricing.BotAccountPrefixes", "rndbot")); + + if (DynamicPricingBumpPercent == 0) + { + LOG_WARN("module", "AHBConfig: AuctionHouseBot.DynamicPricing.BumpPercent must be > 0, using default 8"); + DynamicPricingBumpPercent = 8; + } + + if (DynamicPricingDecayHalfLifeHours == 0) + { + LOG_WARN("module", "AHBConfig: AuctionHouseBot.DynamicPricing.DecayHalfLifeHours must be > 0, using default 72"); + DynamicPricingDecayHalfLifeHours = 72; + } + + if (DynamicPricingMinMultiplier > 1.0) + { + LOG_WARN("module", "AHBConfig: AuctionHouseBot.DynamicPricing.MinMultiplier must be <= 1, using default 0.5"); + DynamicPricingMinMultiplier = 0.5; + } + + if (DynamicPricingMaxMultiplier < 1.0) + { + LOG_WARN("module", "AHBConfig: AuctionHouseBot.DynamicPricing.MaxMultiplier must be >= 1, using default 3.0"); + DynamicPricingMaxMultiplier = 3.0; + } + + if (DynamicPricingMinMultiplier > DynamicPricingMaxMultiplier) + { + LOG_WARN("module", "AHBConfig: AuctionHouseBot.DynamicPricing.MinMultiplier > MaxMultiplier, resetting both to defaults"); + DynamicPricingMinMultiplier = 0.5; + DynamicPricingMaxMultiplier = 3.0; + } + // Flags: item types Vendor_Items = sConfigMgr->GetOption ("AuctionHouseBot.VendorItems" , false); Loot_Items = sConfigMgr->GetOption ("AuctionHouseBot.LootItems" , true); @@ -3502,6 +3545,39 @@ std::set AHBConfig::getCommaSeparatedIntegers(std::string text) return ret; } +std::vector AHBConfig::getCommaSeparatedStrings(std::string text) +{ + std::string value; + std::stringstream stream; + std::vector ret; + + stream.str(text); + + while (std::getline(stream, value, ',')) + { + // trim surrounding whitespace + size_t start = value.find_first_not_of(" \t"); + size_t end = value.find_last_not_of(" \t"); + + if (start == std::string::npos) + { + continue; + } + + value = value.substr(start, end - start + 1); + + // lowercase for case-insensitive prefix comparisons + std::transform(value.begin(), value.end(), value.begin(), ::tolower); + + if (!value.empty()) + { + ret.push_back(value); + } + } + + return ret; +} + void AHBConfig::LoadPriceOverrides() { // Full reload semantics: rows deleted from the table must disappear from @@ -3584,6 +3660,80 @@ uint32 AHBConfig::GetCountOverrideForItem(uint32 itemId) const return 0; } +void AHBConfig::LoadDemandOverrides() +{ + // Full reload semantics: rows deleted from the table must disappear from + // the in-memory map too, not linger until restart. + itemDemand.clear(); + + QueryResult result = WorldDatabase.Query("SELECT item, multiplier, last_bump FROM mod_auctionhousebot_demand"); + + if (!result) + { + // Optional feature: an empty/absent table just means everything stays neutral. + LOG_INFO("module", "AHBConfig: No demand multipliers in mod_auctionhousebot_demand (optional)"); + return; + } + + do + { + Field* fields = result->Fetch(); + uint32 itemId = fields[0].Get(); + double multiplier = fields[1].Get(); + int64 lastBump = fields[2].Get(); + + itemDemand[itemId] = DemandEntry{multiplier, lastBump}; + } while (result->NextRow()); + + LOG_INFO("module", "AHBConfig: Loaded {} demand multipliers from mod_auctionhousebot_demand", itemDemand.size()); +} + +double AHBConfig::GetEffectiveDemandMultiplier(uint32 itemId) const +{ + auto it = itemDemand.find(itemId); + if (it == itemDemand.end()) + { + return 1.0; + } + + double halfLifeHours = DynamicPricingDecayHalfLifeHours > 0 ? double(DynamicPricingDecayHalfLifeHours) : 72.0; + double hoursSince = double(GameTime::GetGameTime().count() - it->second.lastBump) / 3600.0; + + if (hoursSince < 0.0) + { + // Clock moved backwards (e.g. restored DB snapshot) - treat as "just bumped". + hoursSince = 0.0; + } + + double effective = 1.0 + (it->second.multiplier - 1.0) * std::pow(0.5, hoursSince / halfLifeHours); + + if (std::fabs(effective - 1.0) < 0.01) + { + return 1.0; + } + + return effective; +} + +void AHBConfig::BumpDemand(uint32 itemId) +{ + double effective = GetEffectiveDemandMultiplier(itemId); + double bumped = effective * (1.0 + double(DynamicPricingBumpPercent) / 100.0); + bumped = std::clamp(bumped, DynamicPricingMinMultiplier, DynamicPricingMaxMultiplier); + + int64 now = GameTime::GetGameTime().count(); + + itemDemand[itemId] = DemandEntry{bumped, now}; + + WorldDatabase.Execute("REPLACE INTO `mod_auctionhousebot_demand` (`item`, `multiplier`, `last_bump`) VALUES ({}, {}, {})", + itemId, bumped, now); + + if (DebugOut) + { + LOG_INFO("module", "AHBConfig: Bumped demand for item {} to {} (last_bump={})", itemId, bumped, now); + } +} + void AHBConfig::LoadBotGUIDs() { std::string guidsStr = sConfigMgr->GetOption("AuctionHouseBot.GUIDs", "0"); diff --git a/src/AuctionHouseBotConfig.h b/src/AuctionHouseBotConfig.h index 65d7966..de55433 100644 --- a/src/AuctionHouseBotConfig.h +++ b/src/AuctionHouseBotConfig.h @@ -157,6 +157,7 @@ class AHBConfig void InitializeFromSql(std::set botsIds); std::set getCommaSeparatedIntegers(std::string text); + std::vector getCommaSeparatedStrings(std::string text); std::vector botGUIDs; void DecItemCounts(uint32 ahbotItemType); @@ -314,6 +315,27 @@ class AHBConfig uint32 BuyerBidIncrementMinPct; uint32 BuyerBidIncrementMaxPct; + // Demand multiplier: per-item price bump that reacts to real human purchases and + // decays back to neutral over time (mod_auctionhousebot_demand). + struct DemandEntry + { + double multiplier; + int64 lastBump; // unix timestamp + }; + + std::unordered_map itemDemand; + + bool DynamicPricingEnable; + uint32 DynamicPricingBumpPercent; + uint32 DynamicPricingDecayHalfLifeHours; + double DynamicPricingMinMultiplier; + double DynamicPricingMaxMultiplier; + std::vector DynamicPricingBotAccountPrefixes; + + void LoadDemandOverrides(); + double GetEffectiveDemandMultiplier(uint32 itemId) const; + void BumpDemand(uint32 itemId); + // Constructors/destructors AHBConfig(); AHBConfig(uint32 ahid, AHBConfig* conf); diff --git a/src/AuctionHouseBotWorldScript.cpp b/src/AuctionHouseBotWorldScript.cpp index b30c8ad..0247165 100644 --- a/src/AuctionHouseBotWorldScript.cpp +++ b/src/AuctionHouseBotWorldScript.cpp @@ -190,15 +190,25 @@ void AHBot_WorldScript::LoadSharedOverrides() { // Load once using the neutral config, then share across all configurations. // Called on startup and on every config reload, so .reload config keeps - // mod_auctionhousebot_priceOverride and mod_auctionhousebot_countOverride current. + // mod_auctionhousebot_priceOverride, mod_auctionhousebot_countOverride and + // mod_auctionhousebot_demand current. gNeutralConfig->LoadPriceOverrides(); gNeutralConfig->LoadCountOverrides(); + gNeutralConfig->LoadDemandOverrides(); + + // Account verdicts depend on the BotAccountPrefixes option; drop them so a + // changed prefix list takes effect on reload. Regrows bounded by the + // realm's account count. + gAccountHumanCache.clear(); gAllianceConfig->itemPriceOverrides = gNeutralConfig->itemPriceOverrides; gHordeConfig->itemPriceOverrides = gNeutralConfig->itemPriceOverrides; gAllianceConfig->itemCountOverrides = gNeutralConfig->itemCountOverrides; gHordeConfig->itemCountOverrides = gNeutralConfig->itemCountOverrides; + + gAllianceConfig->itemDemand = gNeutralConfig->itemDemand; + gHordeConfig->itemDemand = gNeutralConfig->itemDemand; } void AHBot_WorldScript::PopulateBots()