feat(shop): DB-driven catalog-admin system with byte-exact writer (.reload/.shop)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user