Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions conf/mod_ahbot.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
13 changes: 13 additions & 0 deletions data/sql/db-world/2026_07_02_00_mod_auctionhousebot_demand.sql
Original file line number Diff line number Diff line change
@@ -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;
106 changes: 106 additions & 0 deletions docs/superpowers/specs/2026-07-02-demand-multiplier-design.md
Original file line number Diff line number Diff line change
@@ -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<uint32, DemandEntry>` 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.
8 changes: 8 additions & 0 deletions src/AuctionHouseBot.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
68 changes: 68 additions & 0 deletions src/AuctionHouseBotAuctionHouseScript.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <algorithm>

// 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::string>();
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,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/AuctionHouseBotCommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ AHBConfig* gNeutralConfig = new AHBConfig(7);

std::set<uint32> gBotsId;
std::set<AuctionHouseBot*> gBots;

std::unordered_map<uint32, bool> gAccountHumanCache;
5 changes: 5 additions & 0 deletions src/AuctionHouseBotCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#define AUCTION_HOUSE_BOT_COMMON_H

#include <set>
#include <unordered_map>

#include "Common.h"

Expand Down Expand Up @@ -104,4 +105,8 @@ enum class AHBotCommand : uint32
extern std::set<uint32> gBotsId; // Active bots players ids
extern std::set<AuctionHouseBot*> 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<uint32, bool> gAccountHumanCache;

#endif // AUCTION_HOUSE_BOT_COMMON_H
Loading
Loading