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
67 changes: 67 additions & 0 deletions docs/superpowers/specs/2026-07-01-reasonable-stack-sizes-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Reasonable AH stack sizes — design

Date: 2026-07-01
Status: Approved, implementing.

## Problem

`AuctionHouseBot::getStackCount` picks a stack size with `urand(1, min(stackable, maxStackSize))`
(uniform random, with a `// TODO: This is not good` comment). Result: commodities list at
unrealistic sizes (7, 13, ...) and any item whose template `stackable > 1` (e.g. glyphs on
this dataset) gets quantities a real player would never post. Real players sell glyphs,
recipes, cut gems, etc. one at a time, and post commodities in clean 1/5/10/20/full stacks.

## Approach

Rewrite `getStackCount` to be category-aware and use realistic breakpoints. It takes the
`ItemTemplate*` (already in scope at the two `Sell` call sites) to branch on class /
GemProperties. Pure arithmetic + a `switch` + one `urand` — O(1), no DB lookup, no
allocation (not a performance path). All existing ceilings are preserved: the caller still
passes `item->GetMaxStackCount()` (template `stackable`) and applies the config
`GetMaxStackSize` and per-quality `GetMaxStack` caps.

## Rules (in order)

1. **Always single (return 1)** regardless of template stackable:
- `ITEM_CLASS_GLYPH` (16), `ITEM_CLASS_RECIPE` (9), `ITEM_CLASS_QUEST` (12),
`ITEM_CLASS_CONTAINER` (1), `ITEM_CLASS_KEY` (13)
- `ITEM_CLASS_GEM` (3) **when `prototype->GemProperties != 0`** (cut/socketable gem).
Raw gems (class 3, `GemProperties == 0`) fall through and stack like materials.
2. **Non-stackable** (`max <= 1`) -> 1.
3. **Commodities** -> weighted human breakpoint, capped at `cap = maxStackSize > 0 ?
min(max, maxStackSize) : max`:
- roll `urand(1,100)`: `<=30` -> full stack (`cap`); `<=50` -> `1`; else a random pick of
the breakpoints `{5,10,20}` that are `<= cap` (if none valid, full stack).
- Weights are tunable; the intent is "biased toward what players actually post, never a
random 7/13".

The existing `DivisibleStacks` config branch is kept as an alternate mode for admins who set
it; only the uniform-random default is replaced. Category rules (step 1) apply before either.

## Signature change

- `getStackCount(AHBConfig* config, uint32 max)` -> `getStackCount(AHBConfig* config, uint32 max, ItemTemplate const* prototype)`.
- Update the two call sites in `Sell` (they already have `prototype`) to pass it.
- Header declaration updated to match.

## Safety / perf

- No behaviour change to the `Sell` gating or the quality/config caps — only the number
chosen within the ceiling changes.
- No DB access, no heap allocation; a fixed 3-element local array for the mid breakpoints.
- Degrades sensibly for odd `cap` values (e.g. a `cap` with no valid mid breakpoint returns
the full stack).

## Testing

C++ can't be unit-tested locally (needs the full core). Gates:
- CI `-Werror` build (compile + no unused-param/var).
- TEST-realm validation: confirm glyphs / recipes / cut gems list as quantity 1, raw gems
and commodities (cloth/ore/herbs/potions) list as clean 1/5/10/20/full stacks, no random
odd sizes.

## Out of scope

- The pre-existing `DivisibleStacks` `ret == 0` edge (when `max` isn't divisible by 3/4/5) —
left untouched.
- Any per-item stack override table (rules cover the need; revisit only if exceptions surface).
67 changes: 57 additions & 10 deletions src/AuctionHouseBot.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,34 @@ uint32 AuctionHouseBot::getElement(const std::vector<uint32>& vec, int index, ui
return itemID;
}

uint32 AuctionHouseBot::getStackCount(AHBConfig* config, uint32 max)
uint32 AuctionHouseBot::getStackCount(AHBConfig* config, uint32 max, ItemTemplate const* prototype)
{
uint32 maxStackSize = config->GetMaxStackSize();

if (max == 1)
// Some categories are always sold one at a time, regardless of the template's
// stackable value -- a real player never posts a glyph, recipe or cut gem in a stack.
switch (prototype->Class)
{
case ITEM_CLASS_GLYPH:
case ITEM_CLASS_RECIPE:
case ITEM_CLASS_QUEST:
case ITEM_CLASS_CONTAINER:
case ITEM_CLASS_KEY:
return 1;
case ITEM_CLASS_GEM:
if (prototype->GemProperties != 0) // cut/socketable gem; raw gems stack like mats
return 1;
break;
default:
break;
}

//
// Organize the stacks in a pseudo random way
//
if (max <= 1)
{
return 1;
}

// Optional legacy mode: organize stacks by divisibility.
if (config->DivisibleStacks)
{
uint32 ret = 0;
Expand Down Expand Up @@ -141,9 +156,41 @@ uint32 AuctionHouseBot::getStackCount(AHBConfig* config, uint32 max)
return ret;
}

// Totally random stack sizes...
// TODO: This is not good, we need to find a better way to organize the stacks
return urand(1, std::min(max, maxStackSize));
// Realistic weighted breakpoints -- looks player-made, never a random 7 or 13.
uint32 cap = (maxStackSize > 0) ? std::min(max, maxStackSize) : max;
if (cap <= 1)
{
return 1;
}

uint32 roll = urand(1, 100);
if (roll <= 30)
{
return cap; // full stack
}
if (roll <= 50)
{
return 1; // single
}

// A common human breakpoint that fits under the cap.
uint32 const breakpoints[3] = { 5, 10, 20 };
uint32 valid[3];
uint32 nValid = 0;
for (uint32 i = 0; i < 3; ++i)
{
if (breakpoints[i] <= cap)
{
valid[nValid++] = breakpoints[i];
}
}

if (nValid == 0)
{
return cap; // cap is 2..4: no clean breakpoint, use the full stack
}

return valid[urand(0, nValid - 1)];
}

uint32 AuctionHouseBot::getElapsedTime(uint32 timeClass)
Expand Down Expand Up @@ -985,11 +1032,11 @@ void AuctionHouseBot::Sell(Player* AHBplayer, AHBConfig* config)
// Determine the stack size
if (config->GetMaxStack(prototype->Quality) > 1 && item->GetMaxStackCount() > 1)
{
stackCount = minValue(getStackCount(config, item->GetMaxStackCount()), config->GetMaxStack(prototype->Quality));
stackCount = minValue(getStackCount(config, item->GetMaxStackCount(), prototype), config->GetMaxStack(prototype->Quality));
}
else if (config->GetMaxStack(prototype->Quality) == 0 && item->GetMaxStackCount() > 1)
{
stackCount = getStackCount(config, item->GetMaxStackCount());
stackCount = getStackCount(config, item->GetMaxStackCount(), prototype);
}
else
{
Expand Down
3 changes: 2 additions & 1 deletion src/AuctionHouseBot.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class AHBConfig;
class AuctionHouseObject;

struct AuctionEntry;
struct ItemTemplate;
class Player;
class WorldSession;

Expand Down Expand Up @@ -72,7 +73,7 @@ class AuctionHouseBot
inline uint32 minValue(uint32 a, uint32 b) { return a <= b ? a : b; };

uint32 getNofAuctions(AHBConfig* config, AuctionHouseObject* auctionHouse, ObjectGuid guid);
uint32 getStackCount(AHBConfig* config, uint32 max);
uint32 getStackCount(AHBConfig* config, uint32 max, ItemTemplate const* prototype);
uint32 getElapsedTime(uint32 timeClass);
uint32 getElement(const std::vector<uint32>& vec, int index, uint32 botId, uint32 maxDup, std::unordered_map<uint32, uint32>& botItemCounts);

Expand Down
Loading