feat(shop): DB-driven catalog-admin system with byte-exact writer (.reload/.shop)

This commit is contained in:
luis
2026-08-09 18:44:29 -03:00
parent bf2dbf9b1a
commit 9064738838
10 changed files with 917 additions and 101 deletions
@@ -0,0 +1,10 @@
DELETE FROM `rbac_permissions` WHERE `id` IN (886,887);
INSERT INTO `rbac_permissions` (`id`,`name`) VALUES
(1001,'Command: reload shop_catalog'),
(1002,'Command: shop');
DELETE FROM `rbac_linked_permissions` WHERE `linkedId` IN (886,887);
INSERT INTO `rbac_linked_permissions` (`id`,`linkedId`) VALUES
(196,1001),
(197,1002);
+2
View File
@@ -758,6 +758,8 @@ enum RBACPermissions
//
// custom permissions 1000+
RBAC_PERM_USE_COMMENTATOR_MODE = 1000,
RBAC_PERM_COMMAND_RELOAD_SHOP_CATALOG = 1001,
RBAC_PERM_COMMAND_SHOP = 1002,
RBAC_PERM_MAX
};
@@ -0,0 +1,234 @@
/*
* This file is part of the TrinityCore Project. See AUTHORS file for Copyright information
*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "BattlePayCatalogWriter.h"
#include "Log.h"
#include <algorithm>
#include <cstring>
namespace
{
constexpr uint32 HDR_FIELDS = 7; // result, unk4, count0..count4 (u32 @ 0..27)
constexpr size_t PROD_START = 28;
constexpr size_t HDR_SIZE = 89; // per-record sub-header
constexpr size_t NAME1_LEN_BIT = 428; // record-relative bit offset of the name length (8 bit)
constexpr size_t NAME2_LEN_BIT = 451; // record-relative bit offset of the description length
constexpr size_t FRAME_AFTER_BLOCKA = 18; // mid + tail total between blockA and record end
constexpr uint32 MAX_SIMPLE = 9; // simple-shape slots decodable in the 68275 template
uint32 ReadU32LE(std::vector<uint8> const& b, size_t o)
{
return uint32(b[o]) | (uint32(b[o + 1]) << 8) | (uint32(b[o + 2]) << 16) | (uint32(b[o + 3]) << 24);
}
uint64 ReadU64LE(std::vector<uint8> const& b, size_t o)
{
uint64 v = 0;
for (size_t i = 0; i < 8; ++i)
v |= uint64(b[o + i]) << (8 * i);
return v;
}
void WriteU32LE(std::vector<uint8>& b, size_t o, uint32 v)
{
for (size_t i = 0; i < 4; ++i)
b[o + i] = uint8((v >> (8 * i)) & 0xFF);
}
void WriteU64LE(std::vector<uint8>& b, size_t o, uint64 v)
{
for (size_t i = 0; i < 8; ++i)
b[o + i] = uint8((v >> (8 * i)) & 0xFF);
}
bool Printable(uint8 c) { return c >= 32 && c < 127; }
}
uint32 BattlePayCatalogWriter::ReadBitsMsb(std::vector<uint8> const& buf, size_t bitPos, uint32 width)
{
uint32 v = 0;
for (uint32 i = 0; i < width; ++i)
{
size_t const p = bitPos + i;
uint32 const bit = (buf[p >> 3] >> (7 - (p & 7))) & 1;
v = (v << 1) | bit;
}
return v;
}
void BattlePayCatalogWriter::WriteBitsMsb(std::vector<uint8>& buf, size_t bitPos, uint32 width, uint32 val)
{
for (uint32 i = 0; i < width; ++i)
{
size_t const p = bitPos + i;
uint32 const bit = (val >> (width - 1 - i)) & 1;
uint8 const mask = uint8(1 << (7 - (p & 7)));
uint8& target = buf[p >> 3];
target = uint8((target & ~mask) | (bit ? mask : 0));
}
}
bool BattlePayCatalogWriter::Parse(std::vector<uint8> const& body, std::vector<uint32>& header,
std::vector<BattlePayCatalogRecord>& records, std::vector<uint8>& remainder)
{
header.clear();
records.clear();
remainder.clear();
if (body.size() < PROD_START)
return false;
header.reserve(HDR_FIELDS);
for (uint32 i = 0; i < HDR_FIELDS; ++i)
header.push_back(ReadU32LE(body, i * 4));
size_t pos = PROD_START;
while (records.size() < MAX_SIMPLE && pos + HDR_SIZE < body.size())
{
// Name/description lengths from the record's bit section (fact B).
uint32 const n1 = ReadBitsMsb(body, pos * 8 + NAME1_LEN_BIT, 8);
uint32 const n2 = ReadBitsMsb(body, pos * 8 + NAME2_LEN_BIT, 8);
size_t const blockA = pos + HDR_SIZE;
size_t const blockAEnd = blockA + n1 + n2;
size_t const recEnd = blockAEnd + FRAME_AFTER_BLOCKA + n1; // mid + name3 + tail
// Sanity: a simple-shape record must fit and start with a printable title of the stated length.
if (n1 == 0 || recEnd > body.size())
break;
bool titlePrintable = true;
for (uint32 i = 0; i < n1 && titlePrintable; ++i)
titlePrintable = Printable(body[blockA + i]);
if (!titlePrintable)
break;
std::string const name(reinterpret_cast<char const*>(&body[blockA]), n1);
std::string const description(reinterpret_cast<char const*>(&body[blockA + n1]), n2);
// Locate the repeated title (name3) within the post-blockA framing; mid = bytes before it,
// tail = bytes after. Any valid location reproduces the region byte-exact on serialize.
size_t name3Pos = std::string::npos;
for (size_t off = blockAEnd; off + n1 <= recEnd; ++off)
{
if (std::memcmp(&body[off], name.data(), n1) == 0)
{
name3Pos = off;
break;
}
}
if (name3Pos == std::string::npos || name3Pos < blockAEnd + 2) // need >=2 mid bytes for the name3 length
break;
BattlePayCatalogRecord rec;
rec.NormalPrice = ReadU64LE(body, pos + 0);
rec.CurrentPrice = ReadU64LE(body, pos + 8);
rec.ProductID = ReadU32LE(body, pos + 81);
rec.Flags = ReadU32LE(body, pos + 85);
rec.Name = name;
rec.Description = description;
rec.Header.assign(body.begin() + pos, body.begin() + blockA);
rec.Mid.assign(body.begin() + blockAEnd, body.begin() + name3Pos);
rec.Tail.assign(body.begin() + name3Pos + n1, body.begin() + recEnd);
records.push_back(std::move(rec));
pos = recEnd;
}
remainder.assign(body.begin() + pos, body.end());
return !records.empty();
}
std::vector<uint8> BattlePayCatalogWriter::RebuildRecord(BattlePayCatalogRecord const& rec)
{
uint32 const n1 = uint32(rec.Name.size());
uint32 const n2 = uint32(rec.Description.size());
std::vector<uint8> hdr = rec.Header;
WriteU64LE(hdr, 0, rec.NormalPrice);
WriteU64LE(hdr, 8, rec.CurrentPrice);
WriteU32LE(hdr, 81, rec.ProductID);
WriteU32LE(hdr, 85, rec.Flags);
WriteBitsMsb(hdr, NAME1_LEN_BIT, 8, n1 & 0xFF);
WriteBitsMsb(hdr, NAME2_LEN_BIT, 8, n2 & 0xFF);
std::vector<uint8> mid = rec.Mid;
// name3 (repeated title) length packed at mid[0..1] (inverse of battlepay_wire.py decode_name_len).
uint32 const n3 = n1;
if (mid.size() >= 2)
{
mid[0] = uint8((mid[0] & 0xC0) | ((n3 >> 2) & 0x3F));
mid[1] = uint8((mid[1] & 0x3F) | ((n3 & 3) << 6));
}
std::vector<uint8> out;
out.reserve(hdr.size() + n1 + n2 + mid.size() + n1 + rec.Tail.size());
out.insert(out.end(), hdr.begin(), hdr.end());
out.insert(out.end(), rec.Name.begin(), rec.Name.end());
out.insert(out.end(), rec.Description.begin(), rec.Description.end());
out.insert(out.end(), mid.begin(), mid.end());
out.insert(out.end(), rec.Name.begin(), rec.Name.end()); // name3 == title
out.insert(out.end(), rec.Tail.begin(), rec.Tail.end());
return out;
}
std::vector<uint8> BattlePayCatalogWriter::Serialize(std::vector<uint32> const& header,
std::vector<BattlePayCatalogRecord> const& records, std::vector<uint8> const& remainder)
{
std::vector<uint8> out;
out.reserve(HDR_FIELDS * 4 + remainder.size() + records.size() * 200);
for (uint32 i = 0; i < HDR_FIELDS; ++i)
{
uint32 const v = i < header.size() ? header[i] : 0;
for (size_t b = 0; b < 4; ++b)
out.push_back(uint8((v >> (8 * b)) & 0xFF));
}
for (BattlePayCatalogRecord const& rec : records)
{
std::vector<uint8> const recBytes = RebuildRecord(rec);
out.insert(out.end(), recBytes.begin(), recBytes.end());
}
out.insert(out.end(), remainder.begin(), remainder.end());
return out;
}
bool BattlePayCatalogWriter::SelfCheck(std::vector<uint8> const& templateBlob)
{
std::vector<uint32> header;
std::vector<BattlePayCatalogRecord> records;
std::vector<uint8> remainder;
if (!Parse(templateBlob, header, records, remainder))
{
TC_LOG_ERROR("server.loading", "BattlePayCatalogWriter: self-check FAILED - template did not parse.");
return false;
}
std::vector<uint8> const rebuilt = Serialize(header, records, remainder);
if (rebuilt == templateBlob)
{
TC_LOG_INFO("server.loading", "BattlePayCatalogWriter: self-check PASS - {} simple slots, byte-exact round trip.",
records.size());
return true;
}
size_t firstDiff = 0;
size_t const cmpLen = std::min(rebuilt.size(), templateBlob.size());
while (firstDiff < cmpLen && rebuilt[firstDiff] == templateBlob[firstDiff])
++firstDiff;
TC_LOG_ERROR("server.loading", "BattlePayCatalogWriter: self-check FAILED - sizes {}/{}, first diff @ byte {}.",
rebuilt.size(), templateBlob.size(), firstDiff);
return false;
}
@@ -0,0 +1,78 @@
/*
* This file is part of the TrinityCore Project. See AUTHORS file for Copyright information
*
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef TRINITYCORE_BATTLE_PAY_CATALOG_WRITER_H
#define TRINITYCORE_BATTLE_PAY_CATALOG_WRITER_H
#include "Define.h"
#include <string>
#include <vector>
// One field-decoded "simple shape" product record from the 68275
// SMSG_BATTLE_PAY_GET_PRODUCT_LIST_RESPONSE catalog. This is a C++ port of the byte-exact-proven
// wire writer C:/dumps/battlepay_wire.py (facts B/C/D):
//
// normalPrice / currentPrice : u64 fixed-point (/100000) @ record+0 / +8
// productID : u32 @ record+81
// flags : u32 @ record+85 (BattlepayDisplayFlags)
// name (card title) : length = 8 bits @ record bit 428 (MSB-first); data @ record+89
// description : length = 8 bits @ record bit 451; data right after the name
// name3 (card name repeat) : == the title; its 8-bit length is packed at Mid[0..1]
//
// The record shape in the shipped 68275 template is uniform (verified for all 9 records): an 89-byte
// sub-header, then name, description, a 14-byte "mid" framing block, the title repeated, and a 4-byte
// tail. Header/Mid/Tail are carried verbatim; only the length bits inside them are rewritten.
struct BattlePayCatalogRecord
{
uint64 NormalPrice = 0;
uint64 CurrentPrice = 0;
uint32 ProductID = 0;
uint32 Flags = 0;
std::string Name;
std::string Description;
std::vector<uint8> Header; // 89-byte sub-header (length bits rewritten on serialize)
std::vector<uint8> Mid; // framing between description and the repeated title
std::vector<uint8> Tail; // trailing framing
};
// Byte-exact catalog (de)serializer. Rebuilds the leading simple-shape product records FROM FIELDS
// (a real writer, proven byte-exact against the template) and preserves everything past them verbatim.
class TC_GAME_API BattlePayCatalogWriter
{
public:
// Parses the leading simple-shape records from a catalog body. Returns false if the body is not a
// recognizable 68275 catalog. On success: header = the 7 leading u32s, records = decoded slots,
// remainder = the bytes past the simple-shape prefix (complex records, kept verbatim).
static bool Parse(std::vector<uint8> const& body, std::vector<uint32>& header,
std::vector<BattlePayCatalogRecord>& records, std::vector<uint8>& remainder);
// header + records (rebuilt from fields) + remainder -> catalog body.
static std::vector<uint8> Serialize(std::vector<uint32> const& header,
std::vector<BattlePayCatalogRecord> const& records, std::vector<uint8> const& remainder);
// Self-check: Serialize(Parse(templateBlob)) must equal templateBlob byte-for-byte. The catalog
// reskin approach must not be trusted unless this passes.
static bool SelfCheck(std::vector<uint8> const& templateBlob);
private:
static uint32 ReadBitsMsb(std::vector<uint8> const& buf, size_t bitPos, uint32 width);
static void WriteBitsMsb(std::vector<uint8>& buf, size_t bitPos, uint32 width, uint32 val);
static std::vector<uint8> RebuildRecord(BattlePayCatalogRecord const& rec);
};
#endif // TRINITYCORE_BATTLE_PAY_CATALOG_WRITER_H
+310 -37
View File
@@ -16,12 +16,31 @@
*/
#include "BattlePayMgr.h"
#include "BattlePayCatalogWriter.h"
#include "Config.h"
#include "ConditionMgr.h"
#include "DatabaseEnv.h"
#include "GameTime.h"
#include "Log.h"
#include "Player.h"
#include "StringFormat.h"
#include "Timer.h"
#include "World.h"
#include <algorithm>
#include <fstream>
namespace
{
constexpr uint32 DISPLAY_FLAG_HIDDEN_PRICE = 8;
constexpr uint32 DISPLAY_FLAG_HIDE_WHEN_OWNED = 256;
bool InWindow(ShopProduct const& p, time_t now)
{
return (p.AvailableFrom == 0 || now >= p.AvailableFrom)
&& (p.AvailableUntil == 0 || now <= p.AvailableUntil);
}
}
BattlePayMgr* BattlePayMgr::instance()
{
static BattlePayMgr instance;
@@ -63,14 +82,17 @@ void BattlePayMgr::Load()
{
uint32 const oldMSTime = getMSTime();
if (LoadBlobFile("product_list_68275.bin", _productListBlob))
if (LoadBlobFile("product_list_68275.bin", _templateBlob))
{
++_catalogGeneration;
TC_LOG_INFO("server.loading", "BattlePay: loaded {}-byte in-game Shop catalog in {} ms.",
_productListBlob.size(), GetMSTimeDiffToNow(oldMSTime));
TC_LOG_INFO("server.loading", "BattlePay: loaded {}-byte catalog template in {} ms.",
_templateBlob.size(), GetMSTimeDiffToNow(oldMSTime));
// Trust the reskin path only if the writer reproduces the template byte-exact.
if (!BattlePayCatalogWriter::SelfCheck(_templateBlob))
TC_LOG_ERROR("server.loading", "BattlePay: catalog writer self-check FAILED; catalog will be served verbatim (no DB reskin).");
}
else
TC_LOG_INFO("server.loading", "BattlePay: no catalog blob - the in-game Shop will open empty.");
TC_LOG_INFO("server.loading", "BattlePay: no catalog template - the in-game Shop will open empty.");
// The distribution list unblocks the client's shop panel (StoreFrame_IsLoading). Replay the
// captured 68275 blob; absence is non-fatal (the panel just keeps waiting on HasDistributionList).
@@ -80,48 +102,299 @@ void BattlePayMgr::Load()
void BattlePayMgr::LoadProducts()
{
uint32 const oldMSTime = getMSTime();
_products.clear();
_slotOverrides.clear();
// 0 1 2 3 4 5 6 7
QueryResult result = WorldDatabase.Query("SELECT productId, costMoney, costItemId, costItemCount, grantType, grantId, grantCount, name FROM battlepay_product");
if (!result)
// 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
QueryResult result = WorldDatabase.Query("SELECT productId, enabled, name, description, currency, price, priceItemId, priceItemCount, displayPrice, displayFlags, groupId, ordering, featured, UNIX_TIMESTAMP(availableFrom), UNIX_TIMESTAMP(availableUntil), reqLevel, reqFaction, hideIfOwned, playerConditionId, comment FROM shop_product");
if (result)
{
TC_LOG_INFO("server.loading", "BattlePay: loaded 0 shop products (table `battlepay_product` empty or missing).");
return;
do
{
Field* f = result->Fetch();
ShopProduct p;
p.ProductID = f[0].GetUInt32();
p.Enabled = f[1].GetUInt8() != 0;
p.Name = f[2].GetString();
p.Description = f[3].GetString();
p.Currency = f[4].GetUInt8();
p.Price = f[5].GetUInt64();
p.PriceItemId = f[6].GetUInt32();
p.PriceItemCount = f[7].GetUInt32();
if (!f[8].IsNull())
{
p.HasDisplayPrice = true;
p.DisplayPrice = f[8].GetUInt64();
}
p.DisplayFlags = f[9].GetUInt32();
p.GroupId = f[10].GetUInt32();
p.Ordering = f[11].GetInt32();
p.Featured = f[12].GetUInt8() != 0;
if (!f[13].IsNull())
p.AvailableFrom = time_t(f[13].GetInt64());
if (!f[14].IsNull())
p.AvailableUntil = time_t(f[14].GetInt64());
p.ReqLevel = f[15].GetUInt8();
p.ReqFaction = f[16].GetInt8();
p.HideIfOwned = f[17].GetUInt8() != 0;
p.PlayerConditionId = f[18].GetUInt32();
p.Comment = f[19].GetString();
_products[p.ProductID] = std::move(p);
} while (result->NextRow());
if (QueryResult deliverables = WorldDatabase.Query("SELECT productId, seq, type, id, count FROM shop_product_deliverable ORDER BY productId, seq"))
{
do
{
Field* f = deliverables->Fetch();
uint32 const productId = f[0].GetUInt32();
auto itr = _products.find(productId);
if (itr == _products.end())
{
TC_LOG_ERROR("sql.sql", "BattlePay: shop_product_deliverable references unknown productId {} - skipped.", productId);
continue;
}
ShopDeliverable dv;
dv.Type = f[2].GetUInt8();
dv.Id = f[3].GetUInt32();
dv.Count = f[4].GetUInt32();
if (!dv.Count)
dv.Count = 1;
itr->second.Deliverables.push_back(dv);
} while (deliverables->NextRow());
}
}
do
if (QueryResult overrides = WorldDatabase.Query("SELECT slotIndex, productId FROM shop_slot_override"))
{
Field* fields = result->Fetch();
BattlePayProduct product;
product.ProductID = fields[0].GetUInt32();
product.CostMoney = fields[1].GetUInt64();
product.CostItemId = fields[2].GetUInt32();
product.CostItemCount = fields[3].GetUInt32();
product.GrantType = fields[4].GetUInt8();
product.GrantId = fields[5].GetUInt32();
product.GrantCount = fields[6].GetUInt32();
product.Name = fields[7].GetString();
if (!product.GrantId || (product.GrantType != 1 && product.GrantType != 2))
do
{
TC_LOG_ERROR("sql.sql", "BattlePay: product {} has invalid grantType {} / grantId {} - skipped.",
product.ProductID, product.GrantType, product.GrantId);
continue;
}
if (!product.GrantCount)
product.GrantCount = 1;
Field* f = overrides->Fetch();
_slotOverrides[f[0].GetUInt8()] = f[1].GetUInt32();
} while (overrides->NextRow());
}
_products[product.ProductID] = std::move(product);
} while (result->NextRow());
TC_LOG_INFO("server.loading", "BattlePay: loaded {} shop products in {} ms.", _products.size(), GetMSTimeDiffToNow(oldMSTime));
TC_LOG_INFO("server.loading", "BattlePay: loaded {} shop products, {} slot overrides.", _products.size(), _slotOverrides.size());
}
BattlePayProduct const* BattlePayMgr::GetProduct(uint32 productID) const
bool BattlePayMgr::AssembleCatalog(std::vector<uint8>& outBlob, std::unordered_map<uint32, uint32>& outRouting,
std::string* report) const
{
auto itr = _products.find(productID);
outRouting.clear();
if (_templateBlob.empty())
return false;
std::vector<uint32> header;
std::vector<BattlePayCatalogRecord> records;
std::vector<uint8> remainder;
if (!BattlePayCatalogWriter::Parse(_templateBlob, header, records, remainder))
{
TC_LOG_ERROR("server.loading", "BattlePay: catalog template did not parse; serving it verbatim.");
outBlob = _templateBlob;
return false;
}
time_t const now = GameTime::GetGameTime();
std::string const placeholderName = std::string(sConfigMgr->GetStringDefault("Shop.PlaceholderName", "Currently unavailable"));
// Products pinned to a specific slot are excluded from the automatic fill so they show once only.
std::unordered_map<uint32, bool> pinned;
for (auto const& [slot, productId] : _slotOverrides)
if (productId)
pinned[productId] = true;
// Candidate set = enabled + in-window, not pinned; sorted featured DESC, ordering ASC, productId ASC.
std::vector<ShopProduct const*> candidates;
for (auto const& [id, product] : _products)
if (product.Enabled && InWindow(product, now) && pinned.find(id) == pinned.end())
candidates.push_back(&product);
std::sort(candidates.begin(), candidates.end(), [](ShopProduct const* a, ShopProduct const* b)
{
if (a->Featured != b->Featured) return a->Featured > b->Featured;
if (a->Ordering != b->Ordering) return a->Ordering < b->Ordering;
return a->ProductID < b->ProductID;
});
auto reskin = [&](BattlePayCatalogRecord& rec, ShopProduct const& product)
{
uint64 displayPrice;
if (product.HasDisplayPrice)
displayPrice = product.DisplayPrice;
else if (product.Currency == 1) // gold: copper -> shop fixed-point /100000
displayPrice = (product.Price / 10000) * 100000;
else
displayPrice = 0;
uint32 flags = product.DisplayFlags;
if (!product.HasDisplayPrice && product.Currency != 0 && product.Currency != 1)
flags |= DISPLAY_FLAG_HIDDEN_PRICE; // non-gold currency w/o override: hide the price line
if (product.HideIfOwned)
flags |= DISPLAY_FLAG_HIDE_WHEN_OWNED;
rec.Name = product.Name;
rec.Description = product.Description;
rec.NormalPrice = displayPrice;
rec.CurrentPrice = displayPrice;
rec.Flags = flags;
};
size_t candIdx = 0;
for (size_t slot = 0; slot < records.size(); ++slot)
{
uint32 const slotProductId = records[slot].ProductID;
ShopProduct const* assigned = nullptr;
auto ovr = _slotOverrides.find(uint8(slot));
if (ovr != _slotOverrides.end())
{
if (ovr->second != 0) // pinned; 0 = forced placeholder
assigned = GetProduct(ovr->second);
}
else
{
if (candIdx < candidates.size())
assigned = candidates[candIdx++];
}
if (assigned)
{
reskin(records[slot], *assigned);
outRouting[slotProductId] = assigned->ProductID;
if (report)
report->append(Trinity::StringFormat(" slot {}: [{}] '{}' -> product {} (price {}, {}{})\n",
slot, slotProductId, assigned->Name, assigned->ProductID, assigned->Price,
assigned->Enabled ? "enabled" : "disabled", assigned->Featured ? ", featured" : ""));
}
else
{
records[slot].Name = placeholderName;
records[slot].Description.clear();
records[slot].NormalPrice = 0;
records[slot].CurrentPrice = 0;
records[slot].Flags |= DISPLAY_FLAG_HIDDEN_PRICE;
if (report)
report->append(Trinity::StringFormat(" slot {}: [{}] <placeholder - not purchasable>\n", slot, slotProductId));
}
}
if (report && candIdx < candidates.size())
report->append(Trinity::StringFormat(" OVERFLOW: {} enabled product(s) could not be shown (only {} slots).\n",
candidates.size() - candIdx, records.size()));
outBlob = BattlePayCatalogWriter::Serialize(header, records, remainder);
return true;
}
void BattlePayMgr::LoadCatalog()
{
uint32 const oldMSTime = getMSTime();
LoadProducts();
std::vector<uint8> blob;
std::unordered_map<uint32, uint32> routing;
if (_products.empty())
{
// No DB catalog: serve the raw template so the shop still opens (nothing purchasable).
_productListBlob = _templateBlob;
_slotRouting.clear();
TC_LOG_INFO("server.loading", "BattlePay: no shop_product rows; serving the catalog template verbatim.");
}
else if (AssembleCatalog(blob, routing, nullptr))
{
_productListBlob = std::move(blob);
_slotRouting = std::move(routing);
TC_LOG_INFO("server.loading", "BattlePay: assembled {}-byte catalog ({} routed slots) in {} ms.",
_productListBlob.size(), _slotRouting.size(), GetMSTimeDiffToNow(oldMSTime));
}
else
{
_productListBlob = _templateBlob; // assembly failed: fall back to verbatim
_slotRouting.clear();
}
++_catalogGeneration;
// Schedule the next automatic rebuild at the earliest future window boundary (restart-free rotation).
time_t const now = GameTime::GetGameTime();
_nextRebuildTime = 0;
for (auto const& [id, product] : _products)
{
for (time_t boundary : { product.AvailableFrom, product.AvailableUntil })
if (boundary > now && (_nextRebuildTime == 0 || boundary < _nextRebuildTime))
_nextRebuildTime = boundary;
}
}
void BattlePayMgr::Reload()
{
LoadCatalog();
}
void BattlePayMgr::RebuildIfDue(time_t now)
{
if (_nextRebuildTime != 0 && now >= _nextRebuildTime)
{
TC_LOG_INFO("server.loading", "BattlePay: availability window boundary reached; rebuilding catalog.");
Reload();
}
}
ShopProduct const* BattlePayMgr::GetProduct(uint32 adminProductId) const
{
auto itr = _products.find(adminProductId);
return itr != _products.end() ? &itr->second : nullptr;
}
ShopProduct const* BattlePayMgr::GetProductByAdvertisedId(uint32 advertisedProductId) const
{
auto route = _slotRouting.find(advertisedProductId);
if (route == _slotRouting.end())
return nullptr;
return GetProduct(route->second);
}
bool BattlePayMgr::IsAlreadyFullyOwned(ShopProduct const& product, Player* player)
{
if (product.Deliverables.empty())
return false;
for (ShopDeliverable const& d : product.Deliverables)
{
if (d.Type != 2) // only spell-only bundles count as "ownable"
return false;
if (!player->HasSpell(d.Id))
return false;
}
return true;
}
bool BattlePayMgr::IsPurchasable(ShopProduct const& product, Player* player, time_t now) const
{
if (!player)
return false;
if (!product.Enabled || !InWindow(product, now))
return false;
if (product.ReqLevel && player->GetLevel() < product.ReqLevel)
return false;
if (product.ReqFaction >= 0 && int8(player->GetTeamId()) != product.ReqFaction)
return false;
if (product.HideIfOwned && IsAlreadyFullyOwned(product, player))
return false;
if (product.PlayerConditionId && !ConditionMgr::IsPlayerMeetingCondition(player, product.PlayerConditionId))
return false;
return true;
}
std::string BattlePayMgr::BuildStatusReport() const
{
std::string report = Trinity::StringFormat("In-game Shop catalog: {} product(s), template {} bytes.\n",
_products.size(), _templateBlob.size());
std::vector<uint8> blob;
std::unordered_map<uint32, uint32> routing;
AssembleCatalog(blob, routing, &report);
report.append(Trinity::StringFormat("Assembled blob: {} bytes, generation {}.", blob.size(), _catalogGeneration));
return report;
}
+81 -29
View File
@@ -19,57 +19,101 @@
#define TRINITYCORE_BATTLE_PAY_MGR_H
#include "Define.h"
#include <ctime>
#include <string>
#include <unordered_map>
#include <vector>
// A server-defined shop product: how it is paid for and what it grants. Keyed by the productID that the
// catalog advertises to the client (so a purchase packet's productID maps straight to grant + cost).
struct BattlePayProduct
class Player;
// A single deliverable payload of a shop product (>1 per product = a bundle).
struct ShopDeliverable
{
uint32 ProductID = 0;
uint64 CostMoney = 0; // copper; 0 = free
uint32 CostItemId = 0; // token item id; 0 = none
uint32 CostItemCount = 0;
uint8 GrantType = 0; // 1 = item, 2 = spell (mount/toy/appearance learned as a spell)
uint32 GrantId = 0;
uint32 GrantCount = 1;
std::string Name;
uint8 Type = 0; // 1 item | 2 spell | 3 WoW Token | 4 game-time (reserved) | 5 service (reserved)
uint32 Id = 0; // itemId / spellId / 0 (token) / days / serviceType
uint32 Count = 1;
};
// In-game Shop (BattlePay / StoreUI) backend.
// An admin-defined shop product (row of `shop_product` + its `shop_product_deliverable` rows).
// The catalog wire can only express name/description/price/flags; everything else here is enforced
// server-side at purchase time (IsPurchasable) or used to decide slot assignment.
struct ShopProduct
{
uint32 ProductID = 0; // admin id (routing frees it from the blob's fixed slot ids)
bool Enabled = true;
std::string Name;
std::string Description;
uint8 Currency = 1; // 0 free | 1 gold(copper) | 2 item-token | 3 custom-currency
uint64 Price = 0; // copper (currency 1) or currency amount (3)
uint32 PriceItemId = 0; // currency 2: token item
uint32 PriceItemCount = 0;
bool HasDisplayPrice = false; // wire fixed-point /100000 override (NULL in DB => derived)
uint64 DisplayPrice = 0;
uint32 DisplayFlags = 0; // BattlepayDisplayFlags (8 HiddenPrice, 256 HideWhenOwned)
uint32 GroupId = 0; // stored; rendered only once the ShopEntry region is cracked (SH-7)
int32 Ordering = 0; // slot-assignment priority (lower = earlier slot)
bool Featured = false;
time_t AvailableFrom = 0; // 0 = always
time_t AvailableUntil = 0; // 0 = always
uint8 ReqLevel = 0;
int8 ReqFaction = -1; // -1 any, else TeamId (0 alliance, 1 horde)
bool HideIfOwned = false;
uint32 PlayerConditionId = 0;
std::string Comment;
std::vector<ShopDeliverable> Deliverables;
};
// In-game Shop (BattlePay / StoreUI) backend + catalog administration.
//
// The 12.0.7 GET_PRODUCT_LIST_RESPONSE catalog is a nested reflection bitstream whose per-field bit
// widths are not recoverable offline, so we cannot author a custom catalog field-by-field yet. For P0
// the manager loads a byte-exact catalog blob captured from a real 68275 client session and replays it
// verbatim, so the shop opens and shows real products. The purchase/deliver-for-gold path is layered on
// top later (tracked separately) once the buy flow is grounded.
// The captured 68275 GET_PRODUCT_LIST_RESPONSE blob is a TEMPLATE. LoadCatalog() reskins its 9
// simple-shape slots from the `shop_product` DB rows via the byte-exact BattlePayCatalogWriter and
// records a slot->product routing map, so the offer set is DB-driven and reloadable without a restart.
// Everything the wire cannot carry (enable/disable, windows, level/faction/owned/condition gates) is
// enforced at purchase time by IsPurchasable().
class TC_GAME_API BattlePayMgr
{
public:
static BattlePayMgr* instance();
// Loads the captured catalog blob from <DataDir>/battlepay/product_list_68275.bin (if present).
// Loads the raw template + distribution blobs from <DataDir>/battlepay/.
void Load();
// Loads server-defined purchasable products from the world DB (battlepay_product).
void LoadProducts();
// Reads shop_product / shop_product_deliverable / shop_slot_override, assembles the catalog blob
// from the template, and builds the routing map. Call after Load().
void LoadCatalog();
// Atomic re-run of LoadProducts + LoadCatalog on the world thread (.reload shop_catalog / .shop).
void Reload();
bool HasCatalog() const { return !_productListBlob.empty(); }
std::vector<uint8> const& GetProductListBlob() const { return _productListBlob; }
// Bumped every time the catalog blob is (re)built. A session serves the 58 KB blob at most once per
// generation, so a client that polls GetProductList each shop open is not re-fed the blob until a
// `.reload shop_catalog` changes it (anti-amplification without breaking restart-free rotation).
// Bumped every time the catalog blob is (re)built; drives the once-per-session send throttle.
uint32 GetCatalogGeneration() const { return _catalogGeneration; }
// Distribution list: the client's StoreFrame_IsLoading gate blocks the shop panel until
// HasDistributionList() is true, which only flips once it receives a
// SMSG_BATTLE_PAY_GET_DISTRIBUTION_LIST_RESPONSE. We replay a captured 68275 blob at session start.
// Distribution list (see Load()); unblocks the client's shop panel.
bool HasDistributionList() const { return !_distributionListBlob.empty(); }
std::vector<uint8> const& GetDistributionListBlob() const { return _distributionListBlob; }
BattlePayProduct const* GetProduct(uint32 productID) const;
// Purchase routing: the client buys by the advertised (slot) productID, which the assembly kept;
// this resolves it to the admin ShopProduct. Unrouted (placeholder) slots return nullptr.
ShopProduct const* GetProductByAdvertisedId(uint32 advertisedProductId) const;
// Direct lookup by admin productId (used by the .shop commands).
ShopProduct const* GetProduct(uint32 adminProductId) const;
std::unordered_map<uint32, ShopProduct> const& GetProducts() const { return _products; }
// Purchase-time authority for everything the wire cannot express.
bool IsPurchasable(ShopProduct const& product, Player* player, time_t now) const;
// True if the product's payload is entirely spells the player already knows (never charge for nothing).
static bool IsAlreadyFullyOwned(ShopProduct const& product, Player* player);
// Earliest future availability-window boundary (0 = none); World tick rebuilds when it passes.
time_t GetNextRebuildTime() const { return _nextRebuildTime; }
void RebuildIfDue(time_t now);
// Dry-run assembly summary for `.shop preview` / `.shop list`.
std::string BuildStatusReport() const;
uint64 GeneratePurchaseID() { return ++_purchaseCounter; }
private:
@@ -78,14 +122,22 @@ private:
BattlePayMgr(BattlePayMgr const&) = delete;
BattlePayMgr& operator=(BattlePayMgr const&) = delete;
// Reads a raw blob file from <DataDir>/battlepay/<fileName>; returns true and fills out on success.
bool LoadBlobFile(std::string const& fileName, std::vector<uint8>& out);
void LoadProducts(); // fills _products from the DB
// Assembles the catalog blob into `outBlob` and the routing into `outRouting`; returns false on
// a fatal error (no template / self-check failure). Used by LoadCatalog and BuildStatusReport.
bool AssembleCatalog(std::vector<uint8>& outBlob, std::unordered_map<uint32, uint32>& outRouting,
std::string* report) const;
std::vector<uint8> _templateBlob;
std::vector<uint8> _productListBlob;
std::vector<uint8> _distributionListBlob;
std::unordered_map<uint32, BattlePayProduct> _products;
std::unordered_map<uint32, ShopProduct> _products; // admin productId -> product
std::unordered_map<uint32, uint32> _slotRouting; // advertised (slot) productId -> admin id
std::unordered_map<uint8, uint32> _slotOverrides; // slotIndex -> admin id (0 = placeholder)
uint64 _purchaseCounter = 0;
uint32 _catalogGeneration = 0;
time_t _nextRebuildTime = 0;
};
#define sBattlePayMgr BattlePayMgr::instance()
+62 -34
View File
@@ -20,6 +20,7 @@
#include "BattlePayPackets.h"
#include "DatabaseEnv.h"
#include "DBCEnums.h"
#include "GameTime.h"
#include "Item.h"
#include "ItemTemplate.h"
#include "ItemEnchantmentMgr.h"
@@ -153,68 +154,95 @@ void WorldSession::BattlePayProcessPurchase(uint32 productID)
SendPacket(update.Write());
};
BattlePayProduct const* product = player ? sBattlePayMgr->GetProduct(productID) : nullptr;
// Resolve the advertised (slot) productID to its admin product via the catalog routing map.
// Placeholder / unrouted slots have no product -> not purchasable.
ShopProduct const* product = player ? sBattlePayMgr->GetProductByAdvertisedId(productID) : nullptr;
if (!product)
{
TC_LOG_DEBUG("network", "BattlePay: purchase of unknown product {} by {}.", productID, GetPlayerInfo());
TC_LOG_DEBUG("network", "BattlePay: purchase of unrouted product {} by {}.", productID, GetPlayerInfo());
respond(STATUS_FAILED, RESULT_PRODUCT_NOT_PURCHASABLE, 0);
return;
}
// Already-known spell (mount / toy / appearance) is not purchasable: block BEFORE charging so a
// repeat purchase never takes gold for nothing (audit C-05). The wire flag 256 HideWhenOwned hides
// it client-side, but the server gate is authoritative.
if (product->GrantType == 2 && player->HasSpell(product->GrantId))
// Authoritative server-side gate for everything the wire cannot express (enabled/window/level/
// faction/hideIfOwned/condition). Also refuse a spell-only product the player already fully owns
// so a repeat purchase never takes gold for nothing (audit C-05), regardless of the HideIfOwned flag.
// A product with no deliverables is display-only (e.g. the template's showcase mounts/pets): visible
// in the catalog but not for sale, so it must never report a successful purchase (audit parity - these
// had no battlepay_product row before and returned 57).
time_t const now = GameTime::GetGameTime();
if (product->Deliverables.empty()
|| !sBattlePayMgr->IsPurchasable(*product, player, now)
|| BattlePayMgr::IsAlreadyFullyOwned(*product, player))
{
respond(STATUS_FAILED, RESULT_PRODUCT_NOT_PURCHASABLE, 0);
return;
}
// Cost check (gold and/or a token item).
if (product->CostMoney && !player->HasEnoughMoney(product->CostMoney))
// Reserved / unavailable deliverable types abort the whole purchase BEFORE charging: type 3 (WoW
// Token) needs WowTokenMgr which lives on the wow-token branch; types 4 (game time) / 5 (service)
// are schema-reserved with no delivery impl yet. Purchase result 57 until they land.
for (ShopDeliverable const& d : product->Deliverables)
{
respond(STATUS_FAILED, RESULT_NOT_ENOUGH_BALANCE, product->CostMoney);
if (d.Type < 1 || d.Type > 2)
{
TC_LOG_DEBUG("network", "BattlePay: product {} has unsupported deliverable type {} - refused.", productID, d.Type);
respond(STATUS_FAILED, RESULT_PRODUCT_NOT_PURCHASABLE, 0);
return;
}
}
// Cost check by currency (1 = gold copper, 2 = item token).
if (product->Currency == 1 && product->Price && !player->HasEnoughMoney(product->Price))
{
respond(STATUS_FAILED, RESULT_NOT_ENOUGH_BALANCE, product->Price);
return;
}
if (product->CostItemId && !player->HasItemCount(product->CostItemId, product->CostItemCount))
if (product->Currency == 2 && product->PriceItemId && !player->HasItemCount(product->PriceItemId, product->PriceItemCount))
{
respond(STATUS_FAILED, RESULT_NOT_ENOUGH_BALANCE, product->CostMoney);
respond(STATUS_FAILED, RESULT_NOT_ENOUGH_BALANCE, product->Price);
return;
}
// Grant first; only charge if the grant succeeds so we never take payment without delivering.
bool granted = false;
switch (product->GrantType)
// Grant first; only charge if every deliverable succeeds so we never take payment without delivering.
bool granted = true;
for (ShopDeliverable const& d : product->Deliverables)
{
case 1: // item - full delivery to bags, overflow to mail (no partial-stack-at-full-price)
granted = BattlePayDeliverItem(player, product->GrantId, product->GrantCount);
break;
case 2: // spell (mount / toy / appearance) - LearnSpell routes it into the account-wide
// collection via CollectionMgr, so the mount/toy/appearance is available account-wide
player->LearnSpell(product->GrantId, false);
granted = true;
break;
default:
break;
switch (d.Type)
{
case 1: // item - full delivery to bags, overflow to mail (no partial-stack-at-full-price)
if (!BattlePayDeliverItem(player, d.Id, d.Count))
granted = false;
break;
case 2: // spell (mount / toy / appearance) - LearnSpell routes it into the account-wide
// collection via CollectionMgr; LearnSpell no-ops if a bundled spell is already known
if (!player->HasSpell(d.Id))
player->LearnSpell(d.Id, false);
break;
default:
break;
}
if (!granted)
break;
}
if (!granted)
{
TC_LOG_DEBUG("network", "BattlePay: grant failed for product {} ({}), {} not charged.",
productID, product->Name, GetPlayerInfo());
respond(STATUS_FAILED, RESULT_PRODUCT_NOT_PURCHASABLE, product->CostMoney);
respond(STATUS_FAILED, RESULT_PRODUCT_NOT_PURCHASABLE, product->Price);
return;
}
if (product->CostMoney)
player->ModifyMoney(-int64(product->CostMoney));
if (product->CostItemId)
player->DestroyItemCount(product->CostItemId, product->CostItemCount, true);
if (product->Currency == 1 && product->Price)
player->ModifyMoney(-int64(product->Price));
if (product->Currency == 2 && product->PriceItemId)
player->DestroyItemCount(product->PriceItemId, product->PriceItemCount, true);
TC_LOG_INFO("network", "BattlePay: {} purchased product {} ({}) for {} copper / {}x item {}.",
GetPlayerInfo(), productID, product->Name, product->CostMoney, product->CostItemCount, product->CostItemId);
TC_LOG_INFO("network", "BattlePay: {} purchased product {} ({}) for {} (currency {}).",
GetPlayerInfo(), productID, product->Name, product->Price, product->Currency);
respond(STATUS_DONE, RESULT_OK, product->CostMoney);
respond(STATUS_DONE, RESULT_OK, product->Price);
}
void WorldSession::HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPurchase& startPurchase)
@@ -234,7 +262,7 @@ void WorldSession::HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPu
if (sWorld->getBoolConfig(CONFIG_SHOP_PURCHASE_CONFIRMATION))
{
Player* player = GetPlayer();
BattlePayProduct const* product = player ? sBattlePayMgr->GetProduct(startPurchase.ProductID) : nullptr;
ShopProduct const* product = player ? sBattlePayMgr->GetProductByAdvertisedId(startPurchase.ProductID) : nullptr;
if (!product)
{
WorldPackets::BattlePay::StartPurchaseResponse ack;
@@ -251,7 +279,7 @@ void WorldSession::HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPu
WorldPackets::BattlePay::ConfirmPurchase confirm;
confirm.PurchaseID = purchaseID;
confirm.ProductID = startPurchase.ProductID;
confirm.CurrentPriceFixedPoint = (product->CostMoney / 10000) * 100000; // copper -> shop fixed-point
confirm.CurrentPriceFixedPoint = product->Currency == 1 ? (product->Price / 10000) * 100000 : 0; // copper -> shop fixed-point
confirm.ServerToken = _battlePayConfirmToken;
SendPacket(confirm.Write());
return;
+4 -1
View File
@@ -1652,7 +1652,7 @@ bool World::SetInitialWorldSettings()
TC_LOG_INFO("server.loading", "Loading in-game Shop (BattlePay) catalog...");
sBattlePayMgr->Load();
sBattlePayMgr->LoadProducts();
sBattlePayMgr->LoadCatalog();
TC_LOG_INFO("server.loading", "Loading club finder postings...");
sClubFinderMgr->Load();
@@ -2262,6 +2262,9 @@ void World::Update(uint32 diff)
sWhoListStorageMgr->Update();
}
///- Rebuild the in-game Shop catalog when an availability-window boundary passes (restart-free rotation).
sBattlePayMgr->RebuildIfDue(currentGameTime);
if (IsStopped() || m_timers[WUPDATE_CHANNEL_SAVE].Passed())
{
m_timers[WUPDATE_CHANNEL_SAVE].Reset();
@@ -63,6 +63,7 @@ void AddSC_garrison_commandscript();
void AddSC_delve_commandscript();
void AddSC_conduit_commandscript();
void AddSC_mythic_plus_commandscript();
void AddSC_shop_commandscript();
// The name of this function should match:
// void Add${NameOfDirectory}Scripts()
@@ -115,4 +116,5 @@ void AddCommandsScripts()
AddSC_delve_commandscript();
AddSC_conduit_commandscript();
AddSC_mythic_plus_commandscript();
AddSC_shop_commandscript();
}
+134
View File
@@ -0,0 +1,134 @@
/*
* This file is part of the TrinityCore Project. See AUTHORS file for Copyright information
*
* 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 <http://www.gnu.org/licenses/>.
*/
/* ScriptData
Name: shop_commandscript
%Complete: 100
Comment: In-game Shop (BattlePay) catalog administration commands
Category: commandscripts
EndScriptData */
#include "ScriptMgr.h"
#include "BattlePayMgr.h"
#include "Chat.h"
#include "ChatCommand.h"
#include "DatabaseEnv.h"
#include "Optional.h"
#include "RBAC.h"
#include "StringFormat.h"
#include <cstdlib>
#include <sstream>
using namespace Trinity::ChatCommands;
class shop_commandscript : public CommandScript
{
public:
shop_commandscript() : CommandScript("shop_commandscript") { }
std::span<ChatCommandBuilder const> GetCommands() const override
{
static ChatCommandTable shopCommandTable =
{
{ "list", HandleShopListCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
{ "preview", HandleShopListCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
{ "enable", HandleShopEnableCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
{ "disable", HandleShopDisableCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
{ "price", HandleShopPriceCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
{ "window", HandleShopWindowCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
{ "feature", HandleShopFeatureCommand, rbac::RBAC_PERM_COMMAND_SHOP, Console::Yes },
};
static ChatCommandTable commandTable =
{
{ "shop", shopCommandTable },
};
return commandTable;
}
// Validates the product exists, runs a synchronous UPDATE (so the row is committed before the reload
// reads it), rebuilds the catalog, and reports. setClause is core-built from integers only.
static bool MutateAndReload(ChatHandler* handler, uint32 productId, std::string const& setClause)
{
if (!sBattlePayMgr->GetProduct(productId))
{
handler->SendSysMessage(Trinity::StringFormat("No shop product with productId {}.", productId));
handler->SetSentErrorMessage(true);
return false;
}
WorldDatabase.DirectExecute(Trinity::StringFormat("UPDATE `shop_product` SET {} WHERE `productId`={}",
setClause, productId).c_str());
sBattlePayMgr->Reload();
handler->SendSysMessage(Trinity::StringFormat("Shop product {} updated; catalog rebuilt (generation {}).",
productId, sBattlePayMgr->GetCatalogGeneration()));
return true;
}
static bool HandleShopListCommand(ChatHandler* handler)
{
std::string const report = sBattlePayMgr->BuildStatusReport();
std::istringstream stream(report);
std::string line;
while (std::getline(stream, line))
if (!line.empty())
handler->SendSysMessage(line);
return true;
}
static bool HandleShopEnableCommand(ChatHandler* handler, uint32 productId)
{
return MutateAndReload(handler, productId, "`enabled`=1");
}
static bool HandleShopDisableCommand(ChatHandler* handler, uint32 productId)
{
return MutateAndReload(handler, productId, "`enabled`=0");
}
static bool HandleShopPriceCommand(ChatHandler* handler, uint32 productId, uint64 amount, Optional<uint8> currency)
{
std::string setClause = Trinity::StringFormat("`price`={}", amount);
if (currency)
setClause += Trinity::StringFormat(", `currency`={}", uint32(*currency));
return MutateAndReload(handler, productId, setClause);
}
// from/until are unix epoch seconds, or "-" to clear (NULL = always available on that side).
static bool HandleShopWindowCommand(ChatHandler* handler, uint32 productId, std::string from, std::string until)
{
auto boundary = [](std::string const& token) -> std::string
{
if (token == "-")
return "NULL";
return Trinity::StringFormat("FROM_UNIXTIME({})", strtoull(token.c_str(), nullptr, 10));
};
std::string const setClause = Trinity::StringFormat("`availableFrom`={}, `availableUntil`={}",
boundary(from), boundary(until));
return MutateAndReload(handler, productId, setClause);
}
static bool HandleShopFeatureCommand(ChatHandler* handler, uint32 productId, uint8 featured)
{
return MutateAndReload(handler, productId, Trinity::StringFormat("`featured`={}", featured ? 1 : 0));
}
};
void AddSC_shop_commandscript()
{
new shop_commandscript();
}