Shop: branch OPEN_CHECKOUT by payment rail + add license-request handler
This commit is contained in:
@@ -436,6 +436,24 @@ int32 BattlePayMgr::GetCharacterBoostType()
|
||||
return int32(sWorld->getIntConfig(CONFIG_SHOP_CHARACTER_BOOST_TYPE));
|
||||
}
|
||||
|
||||
ShopRealMoneyMode BattlePayMgr::GetRealMoneyMode()
|
||||
{
|
||||
// Read the string directly from the config: World has no string-config array, and this is queried
|
||||
// only on OPEN_CHECKOUT of a real-money product (rare), so a per-call lookup is fine. Default
|
||||
// "instant" so a fresh test realm can buy real-money products for QA without a web backend.
|
||||
std::string const mode = sConfigMgr->GetStringDefault("Shop.RealMoney.Mode", "instant");
|
||||
if (mode == "web")
|
||||
return SHOP_REAL_MONEY_WEB;
|
||||
if (mode == "disabled")
|
||||
return SHOP_REAL_MONEY_DISABLED;
|
||||
if (mode == "instant")
|
||||
return SHOP_REAL_MONEY_INSTANT;
|
||||
|
||||
TC_LOG_ERROR("server.loading", "BattlePay: Shop.RealMoney.Mode = '{}' is not one of web|instant|disabled; "
|
||||
"using 'instant'.", mode);
|
||||
return SHOP_REAL_MONEY_INSTANT;
|
||||
}
|
||||
|
||||
bool BattlePayMgr::IsCharacterBoostProduct(ShopProduct const& product)
|
||||
{
|
||||
return GetServiceType(product) == SHOP_SERVICE_CHARACTER_BOOST;
|
||||
|
||||
@@ -29,6 +29,29 @@
|
||||
|
||||
class Player;
|
||||
|
||||
// Payment method of a shop product (ShopProduct::Currency). Distinct from the client's CurrencyTypes.db2
|
||||
// ids - this is how the SERVER charges. REAL_MONEY is the Rail A marker: such a product is never charged
|
||||
// on the wire; its checkout goes to the shop2 web overlay (or is granted instantly for QA) per
|
||||
// Shop.RealMoney.Mode. The COMMERCE_MASTER_PLAN's "CurrencyTypesID == 0 (real money)" maps to this value,
|
||||
// because value 0 here already means "free".
|
||||
enum ShopCurrency : uint8
|
||||
{
|
||||
SHOP_CURRENCY_FREE = 0, // no cost
|
||||
SHOP_CURRENCY_GOLD = 1, // copper
|
||||
SHOP_CURRENCY_ITEM_TOKEN = 2, // a token item (PriceItemId x PriceItemCount)
|
||||
SHOP_CURRENCY_CUSTOM = 3, // custom currency amount
|
||||
SHOP_CURRENCY_REAL_MONEY = 4 // real money -> Rail A (web checkout); never charged on the wire
|
||||
};
|
||||
|
||||
// Server behaviour when a real-money (Rail A) product's checkout is opened. Read from the
|
||||
// Shop.RealMoney.Mode config string; see BattlePayMgr::GetRealMoneyMode.
|
||||
enum ShopRealMoneyMode : uint8
|
||||
{
|
||||
SHOP_REAL_MONEY_WEB = 0, // let the client web-checkout; the realm sends no game response
|
||||
SHOP_REAL_MONEY_INSTANT = 1, // grant server-side immediately with no charge (private-realm QA)
|
||||
SHOP_REAL_MONEY_DISABLED = 2 // refuse: no SSO token, no grant
|
||||
};
|
||||
|
||||
// A single deliverable payload of a shop product (>1 per product = a bundle).
|
||||
struct ShopDeliverable
|
||||
{
|
||||
@@ -106,7 +129,7 @@ struct ShopProduct
|
||||
bool Enabled = true;
|
||||
std::string Name;
|
||||
std::string Description;
|
||||
uint8 Currency = 1; // 0 free | 1 gold(copper) | 2 item-token | 3 custom-currency
|
||||
uint8 Currency = 1; // ShopCurrency: 0 free | 1 gold | 2 item-token | 3 custom | 4 real-money(web)
|
||||
uint64 Price = 0; // copper (currency 1) or currency amount (3)
|
||||
uint32 PriceItemId = 0; // currency 2: token item
|
||||
uint32 PriceItemCount = 0;
|
||||
@@ -194,6 +217,11 @@ public:
|
||||
// The VAS service type a product's type-5 deliverable names, or 0 if it has none.
|
||||
static uint8 GetServiceType(ShopProduct const& product);
|
||||
|
||||
// How the realm treats a real-money (Rail A) checkout, from the Shop.RealMoney.Mode config string
|
||||
// ("web" | "instant" | "disabled"; unknown values fall back to the default). See the OPEN_CHECKOUT
|
||||
// rail branch in HandleBattlePayOpenCheckout.
|
||||
static ShopRealMoneyMode GetRealMoneyMode();
|
||||
|
||||
// Allocates the next DistributionID (realm-namespaced, seeded from the store at startup).
|
||||
uint64 GenerateDistributionID() { return ++_distributionCounter; }
|
||||
|
||||
|
||||
@@ -591,11 +591,103 @@ void WorldSession::HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenChec
|
||||
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED))
|
||||
return;
|
||||
|
||||
// Retail answers CMSG_BATTLE_PAY_OPEN_CHECKOUT with SMSG_GENERATE_SSO_TOKEN_RESPONSE as a strict
|
||||
// 1:1 echo of the request's ClientToken (proven in all 8 captures: checkout #N -> response #N with
|
||||
// the same u32). Answering from here - rather than pushing the token unsolicited at login - is what
|
||||
// lets checkouts #2+ get a reply. See COMMERCE_AUDIT C-09 / WOW_TOKEN_RE_68275.md.
|
||||
SendGenerateSsoToken(openCheckout.ClientToken);
|
||||
// Two payment rails, decided by the checked-out product (resolve the advertised productID -> catalog):
|
||||
//
|
||||
// Rail B - IN-GAME CURRENCY. A product we route whose Currency is anything but real money is bought on
|
||||
// the wire. Keep the proven handshake: SMSG_GENERATE_SSO_TOKEN_RESPONSE echoing the request's
|
||||
// ClientToken 1:1 (COMMERCE_AUDIT C-09 / WOW_TOKEN_RE_68275.md). Answering from here - not from an
|
||||
// unsolicited push at login - is what lets checkouts #2+ get a reply.
|
||||
//
|
||||
// Rail A - REAL MONEY. Either a product explicitly flagged Currency 4 (real money), OR an un-reskinned
|
||||
// retail catalog card we do not route to a shop_product (e.g. the Midnight expansion, ProductID
|
||||
// 0x417070). The 12.1.0.69382 sniff proves the client gets NO game-packet response for such a
|
||||
// product and opens the shop2 web overlay itself; the old 1:1 SSO echo here was only ever correct for
|
||||
// in-game-currency items. Behaviour is governed by Shop.RealMoney.Mode.
|
||||
ShopProduct const* product = sBattlePayMgr->GetProductByAdvertisedId(openCheckout.ProductID);
|
||||
if (product && product->Currency != SHOP_CURRENCY_REAL_MONEY)
|
||||
{
|
||||
SendGenerateSsoToken(openCheckout.ClientToken);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (BattlePayMgr::GetRealMoneyMode())
|
||||
{
|
||||
case SHOP_REAL_MONEY_INSTANT:
|
||||
{
|
||||
// Grant the product server-side with no charge (private-realm QA default). Reuse the proven
|
||||
// delivery path: a real-money product carries Currency 4, which the charge checks (== gold,
|
||||
// == item token) never match, so nothing is deducted. An unrouted retail card has no
|
||||
// shop_product to grant - honest no-op rather than a faked success.
|
||||
if (!product)
|
||||
{
|
||||
TC_LOG_INFO("network", "BattlePay: {} opened a real-money checkout for unrouted product {} with "
|
||||
"Shop.RealMoney.Mode=instant, but no shop_product is seeded for it - nothing to grant.",
|
||||
GetPlayerInfo(), openCheckout.ProductID);
|
||||
return;
|
||||
}
|
||||
|
||||
// The client resends OPEN_CHECKOUT on each web-payment failure (7 retries in the capture);
|
||||
// throttle identical rapid retries so instant mode grants exactly once, reusing the same
|
||||
// per-session guard as the StartPurchase path.
|
||||
static constexpr uint32 BATTLEPAY_INSTANT_THROTTLE_MS = 2000;
|
||||
uint32 const now = getMSTime();
|
||||
if (_battlePayPurchaseInFlight ||
|
||||
(_lastBattlePayPurchaseMSTime && getMSTimeDiff(_lastBattlePayPurchaseMSTime, now) < BATTLEPAY_INSTANT_THROTTLE_MS))
|
||||
{
|
||||
TC_LOG_DEBUG("network", "BattlePay: throttled duplicate real-money instant checkout from {} (product {}).",
|
||||
GetPlayerInfo(), openCheckout.ProductID);
|
||||
return;
|
||||
}
|
||||
_lastBattlePayPurchaseMSTime = now;
|
||||
|
||||
TC_LOG_INFO("network", "BattlePay: {} real-money checkout for product {} granted instantly "
|
||||
"(Shop.RealMoney.Mode=instant, no charge).", GetPlayerInfo(), openCheckout.ProductID);
|
||||
|
||||
_battlePayPurchaseInFlight = true;
|
||||
BattlePayProcessPurchase(openCheckout.ProductID);
|
||||
_battlePayPurchaseInFlight = false;
|
||||
return;
|
||||
}
|
||||
case SHOP_REAL_MONEY_DISABLED:
|
||||
// Refuse politely: no SSO token, no grant. The client opened its own web overlay on
|
||||
// OPEN_CHECKOUT and the realm cannot recall that, but it neither authenticates the web session
|
||||
// nor delivers anything, so the purchase cannot complete. Logged for the operator.
|
||||
TC_LOG_INFO("network", "BattlePay: {} attempted a real-money checkout for product {} but "
|
||||
"Shop.RealMoney.Mode=disabled - refused (no SSO, no grant).", GetPlayerInfo(), openCheckout.ProductID);
|
||||
return;
|
||||
case SHOP_REAL_MONEY_WEB:
|
||||
default:
|
||||
// Sniff-accurate: the client already opened the shop2 HTTPS overlay from OPEN_CHECKOUT, so the
|
||||
// game connection sends NO response. Completion, if any, arrives from the web backend (Rail A3).
|
||||
TC_LOG_DEBUG("network", "BattlePay: {} opened a real-money web checkout for product {} (client token {}); "
|
||||
"no game response (Shop.RealMoney.Mode=web).", GetPlayerInfo(), openCheckout.ProductID, openCheckout.ClientToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. The client sends this mid-checkout carrying its own
|
||||
// license / game data (variable body: 876/140/52/36/24 bytes in the 12.1.0.69382 capture). Retail answers
|
||||
// on SMSG opcode 0x4202C1 - which our opcode map currently mis-names SMSG_VAS_GET_QUEUE_MINUTES_RESPONSE
|
||||
// (the sniff's 0x4502C1 is that opcode plus the sniff's fixed 0x030000 server-opcode bias) - with an
|
||||
// 11465/1025/423/403/40-byte payload.
|
||||
//
|
||||
// That response is a nested reflection bitstream whose FIELD LAYOUT is not recovered - the capture gives
|
||||
// only the sizes, not the member types. Emitting a guessed layout on that opcode would risk the client's
|
||||
// reflection parser rejecting it and disconnecting, which is exactly the "do not invent wire" hazard. So
|
||||
// this handler does the honest, safe thing: it makes the opcode an EXPECTED, real handler (no more
|
||||
// STATUS_UNHANDLED / Handle_NULL), logs the body size so a future capture can be matched to a variant, and
|
||||
// sends nothing. The 69382 capture shows the checkout proceeds (the client drives its own web overlay from
|
||||
// OPEN_CHECKOUT) without the realm answering this, so withholding the un-modeled response does not stall
|
||||
// the client. When the response wire is recovered, this is where it is sent.
|
||||
void WorldSession::HandleCatalogShopLicenseGameDataRequest(WorldPackets::BattlePay::CatalogShopLicenseGameDataRequest& request)
|
||||
{
|
||||
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED))
|
||||
return;
|
||||
|
||||
TC_LOG_INFO("network", "BattlePay: CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST from {} ({} body bytes). The paired "
|
||||
"response (SMSG 0x4202C1) is an un-modeled reflection payload and is deliberately not answered - see the "
|
||||
"handler note. If the client stalls waiting for it, capture the response bytes and model them here.",
|
||||
GetPlayerInfo(), request.Data.size());
|
||||
}
|
||||
|
||||
namespace
|
||||
|
||||
@@ -178,6 +178,18 @@ namespace WorldPackets::BattlePay
|
||||
void OpenCheckout::Read()
|
||||
{
|
||||
_worldPacket >> ClientToken;
|
||||
_worldPacket >> ProductID;
|
||||
}
|
||||
|
||||
void CatalogShopLicenseGameDataRequest::Read()
|
||||
{
|
||||
// The response wire is not yet modeled, so this body is retained for diagnostics only (its size
|
||||
// identifies which of the captured request variants it is). Consume the whole body so the packet
|
||||
// is not reported as under-read.
|
||||
size_t const len = _worldPacket.size();
|
||||
Data.resize(len);
|
||||
if (len)
|
||||
_worldPacket.read(Data.data(), len);
|
||||
}
|
||||
|
||||
WorldPacket const* StartPurchaseResponse::Write()
|
||||
|
||||
@@ -47,6 +47,23 @@ namespace WorldPackets
|
||||
void Read() override {}
|
||||
};
|
||||
|
||||
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. Sent by the client mid-checkout carrying its own
|
||||
// license / game data. The body is variable (876/140/52/36/24 bytes across the 12.1.0.69382
|
||||
// capture). We keep the whole body so the handler can log its size for future response modeling;
|
||||
// the paired response wire (SMSG 0x4202C1) is not yet recovered - see
|
||||
// WorldSession::HandleCatalogShopLicenseGameDataRequest.
|
||||
class CatalogShopLicenseGameDataRequest final : public ClientPacket
|
||||
{
|
||||
public:
|
||||
explicit CatalogShopLicenseGameDataRequest(WorldPacket&& packet)
|
||||
: ClientPacket(CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST, std::move(packet)) {
|
||||
}
|
||||
|
||||
void Read() override;
|
||||
|
||||
std::vector<uint8> Data; // raw request body, retained for diagnostics only
|
||||
};
|
||||
|
||||
// The 12.0.7 catalog is a nested reflection bitstream that cannot be re-serialized field-by-field
|
||||
// offline (see docs). For P0 we replay a byte-exact, client-validated catalog blob captured from a
|
||||
// real 68275 session, so the shop opens and displays real products. RawData is the message BODY
|
||||
@@ -403,9 +420,10 @@ namespace WorldPackets
|
||||
bool Flag = false;
|
||||
};
|
||||
|
||||
// Client opens the checkout. The u32 is the ClientToken the server must echo back verbatim in
|
||||
// SMSG_GENERATE_SSO_TOKEN_RESPONSE (proven 1:1 in all 8 captures - checkout #N -> response #N
|
||||
// with the same u32). It is not a distributionID. See COMMERCE_AUDIT C-09.
|
||||
// Client opens the checkout. Body is 8 bytes: { u32 ClientToken, u32 ProductID } (12.1.0.69382
|
||||
// capture, Midnight ProductID 0x417070 in all 7 attempts). ClientToken is the token the SSO/token
|
||||
// handshake echoes back verbatim for an in-game-currency product (COMMERCE_AUDIT C-09); ProductID
|
||||
// selects the product and decides the payment rail - see HandleBattlePayOpenCheckout.
|
||||
class OpenCheckout final : public ClientPacket
|
||||
{
|
||||
public:
|
||||
@@ -414,6 +432,7 @@ namespace WorldPackets
|
||||
void Read() override;
|
||||
|
||||
uint32 ClientToken = 0;
|
||||
uint32 ProductID = 0;
|
||||
};
|
||||
|
||||
// Server-driven purchase confirmation prompt (retail interposes this between StartPurchase and
|
||||
|
||||
@@ -285,7 +285,7 @@ void OpcodeTable::InitializeClientOpcodes()
|
||||
DEFINE_HANDLER(CMSG_CAN_DUEL, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleCanDuel);
|
||||
DEFINE_HANDLER(CMSG_CAN_REDEEM_TOKEN_FOR_BALANCE, STATUS_AUTHED, PROCESS_THREADUNSAFE, &WorldSession::HandleCanRedeemTokenForBalance);
|
||||
DEFINE_HANDLER(CMSG_CAST_SPELL, STATUS_LOGGEDIN, PROCESS_THREADSAFE, &WorldSession::HandleCastSpellOpcode);
|
||||
DEFINE_HANDLER(CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST, STATUS_UNHANDLED, PROCESS_THREADUNSAFE, &WorldSession::Handle_NULL);
|
||||
DEFINE_HANDLER(CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST, STATUS_AUTHED, PROCESS_THREADUNSAFE, &WorldSession::HandleCatalogShopLicenseGameDataRequest);
|
||||
DEFINE_HANDLER(CMSG_CHALLENGE_MODE_REQUEST_LEADERS, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::HandleChallengeModeRequestLeaders);
|
||||
DEFINE_HANDLER(CMSG_CHANGE_BAG_SLOT_FLAG, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::HandleChangeBagSlotFlag);
|
||||
DEFINE_HANDLER(CMSG_CHANGE_BANK_BAG_SLOT_FLAG, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::Handle_NULL);
|
||||
|
||||
@@ -135,6 +135,7 @@ namespace WorldPackets
|
||||
class GetPurchaseList;
|
||||
class StartPurchase;
|
||||
class OpenCheckout;
|
||||
class CatalogShopLicenseGameDataRequest;
|
||||
class ConfirmPurchaseResponse;
|
||||
class DistributionAssignToTarget;
|
||||
class CharacterUpgradeStart;
|
||||
@@ -2396,6 +2397,7 @@ public:
|
||||
void HandleBattlePayGetPurchaseList(WorldPackets::BattlePay::GetPurchaseList& getPurchaseList);
|
||||
void HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPurchase& startPurchase);
|
||||
void HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenCheckout & openCheckout);
|
||||
void HandleCatalogShopLicenseGameDataRequest(WorldPackets::BattlePay::CatalogShopLicenseGameDataRequest& request);
|
||||
void BattlePayProcessPurchase(uint32 productID);
|
||||
void HandleBattlePayConfirmPurchaseResponse(WorldPackets::BattlePay::ConfirmPurchaseResponse& confirmPurchaseResponse);
|
||||
void SendBattlePayDistributionList();
|
||||
|
||||
Reference in New Issue
Block a user