feat(game): sell goods in the shops
This commit is contained in:
1 parent
60a20b7c7c
commit
a6d15b6262
12 files changed
+447
No files matched your search
@@ -251,6 +251,10 @@ ChallengePeakSettleScNotify
|
|||||||
# ---- inventory ----
|
# ---- inventory ----
|
||||||
GetBagCsReq
|
GetBagCsReq
|
||||||
GetBagScRsp
|
GetBagScRsp
|
||||||
|
GetShopListCsReq
|
||||||
|
GetShopListScRsp
|
||||||
|
BuyGoodsCsReq
|
||||||
|
BuyGoodsScRsp
|
||||||
UseItemCsReq
|
UseItemCsReq
|
||||||
UseItemScRsp
|
UseItemScRsp
|
||||||
SellItemCsReq
|
SellItemCsReq
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ void registerChallengeHandlers();
|
|||||||
void registerTierceHandlers();
|
void registerTierceHandlers();
|
||||||
void registerPeakHandlers();
|
void registerPeakHandlers();
|
||||||
void registerGachaHandlers();
|
void registerGachaHandlers();
|
||||||
|
void registerShopHandlers();
|
||||||
void registerModuleHandlers();
|
void registerModuleHandlers();
|
||||||
void registerMiscHandlers();
|
void registerMiscHandlers();
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,41 @@ void grant(Player& player, const std::vector<data::ItemStack>& items) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint64_t held(const Player& player, uint32_t id) {
|
||||||
|
const Inventory& bag = player.inventory();
|
||||||
|
switch (id) {
|
||||||
|
case kStellarJade: return bag.hcoin;
|
||||||
|
case kCredit: return bag.scoin;
|
||||||
|
case kOneiricShard: return bag.mcoin;
|
||||||
|
case kStamina: return bag.stamina;
|
||||||
|
case kReserveStamina: return bag.reserveStamina;
|
||||||
|
default: {
|
||||||
|
auto it = bag.items.find(id);
|
||||||
|
return it == bag.items.end() ? 0 : it->second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool spend(Player& player, const std::vector<data::ItemStack>& items) {
|
||||||
|
std::vector<data::ItemStack> total = merged(items);
|
||||||
|
for (const data::ItemStack& stack : total) {
|
||||||
|
if (held(player, stack.id) < stack.num) return false;
|
||||||
|
}
|
||||||
|
Inventory& bag = player.inventory();
|
||||||
|
for (const data::ItemStack& stack : total) {
|
||||||
|
switch (stack.id) {
|
||||||
|
case kStellarJade: bag.hcoin -= stack.num; break;
|
||||||
|
case kCredit: bag.scoin -= stack.num; break;
|
||||||
|
case kOneiricShard: bag.mcoin -= stack.num; break;
|
||||||
|
case kStamina: bag.stamina -= stack.num; break;
|
||||||
|
case kReserveStamina: bag.reserveStamina -= stack.num; break;
|
||||||
|
default:
|
||||||
|
if ((bag.items[stack.id] -= stack.num) == 0) bag.items.erase(stack.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const data::MappingInfo* dropTable(uint32_t mappingInfoId, uint32_t worldLevel) {
|
const data::MappingInfo* dropTable(uint32_t mappingInfoId, uint32_t worldLevel) {
|
||||||
if (mappingInfoId == 0) return nullptr;
|
if (mappingInfoId == 0) return nullptr;
|
||||||
const data::Tables& tables = data::Tables::get();
|
const data::Tables& tables = data::Tables::get();
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ void chargeStamina(Player& player, uint32_t cost, int64_t now);
|
|||||||
// Puts items where they go: the wallet, stamina, or the bag up to each item's pile limit.
|
// Puts items where they go: the wallet, stamina, or the bag up to each item's pile limit.
|
||||||
// Trailblaze EXP has nowhere to go while the level is fixed.
|
// Trailblaze EXP has nowhere to go while the level is fixed.
|
||||||
void grant(Player& player, const std::vector<data::ItemStack>& items);
|
void grant(Player& player, const std::vector<data::ItemStack>& items);
|
||||||
|
// How many of `id` the player holds, wherever it is kept.
|
||||||
|
uint64_t held(const Player& player, uint32_t id);
|
||||||
|
// Takes every stack, or nothing at all when any of them is short.
|
||||||
|
bool spend(Player& player, const std::vector<data::ItemStack>& items);
|
||||||
|
|
||||||
// A farming spot's drop table at `worldLevel`, or at the nearest world level that lists
|
// A farming spot's drop table at `worldLevel`, or at the nearest world level that lists
|
||||||
// any drops: the tables leave some levels empty.
|
// any drops: the tables leave some levels empty.
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ void registerAllHandlers() {
|
|||||||
registerTierceHandlers();
|
registerTierceHandlers();
|
||||||
registerPeakHandlers();
|
registerPeakHandlers();
|
||||||
registerGachaHandlers();
|
registerGachaHandlers();
|
||||||
|
registerShopHandlers();
|
||||||
registerModuleHandlers();
|
registerModuleHandlers();
|
||||||
registerMiscHandlers();
|
registerMiscHandlers();
|
||||||
logging::info("game", "{} packet handlers registered, {} answered empty",
|
logging::info("game", "{} packet handlers registered, {} answered empty",
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ struct ChallengeRecord {
|
|||||||
std::vector<uint32_t> teams[2];
|
std::vector<uint32_t> teams[2];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// How often one goods was bought in the refresh period `period` names. Saved.
|
||||||
|
struct GoodsPurchase {
|
||||||
|
uint32_t times = 0;
|
||||||
|
int64_t period = 0;
|
||||||
|
};
|
||||||
|
|
||||||
// A challenge run in progress. Not persisted: leaving the client mid-floor drops the
|
// A challenge run in progress. Not persisted: leaving the client mid-floor drops the
|
||||||
// run, which is what the real server does with an expired season anyway.
|
// run, which is what the real server does with an expired season anyway.
|
||||||
struct ChallengeRun {
|
struct ChallengeRun {
|
||||||
@@ -206,6 +212,9 @@ public:
|
|||||||
// Season id -> bitmask over the star counts whose reward has been claimed.
|
// Season id -> bitmask over the star counts whose reward has been claimed.
|
||||||
std::map<uint32_t, uint64_t>& challengeRewardsTaken() { return challengeRewardsTaken_; }
|
std::map<uint32_t, uint64_t>& challengeRewardsTaken() { return challengeRewardsTaken_; }
|
||||||
const std::map<uint32_t, uint64_t>& challengeRewardsTaken() const { return challengeRewardsTaken_; }
|
const std::map<uint32_t, uint64_t>& challengeRewardsTaken() const { return challengeRewardsTaken_; }
|
||||||
|
// Keyed by goods id.
|
||||||
|
std::map<uint32_t, GoodsPurchase>& goodsPurchases() { return goodsPurchases_; }
|
||||||
|
const std::map<uint32_t, GoodsPurchase>& goodsPurchases() const { return goodsPurchases_; }
|
||||||
|
|
||||||
uint32_t mainCharacter() const { return mainCharacter_; }
|
uint32_t mainCharacter() const { return mainCharacter_; }
|
||||||
void setMainCharacter(uint32_t value) { mainCharacter_ = value; }
|
void setMainCharacter(uint32_t value) { mainCharacter_ = value; }
|
||||||
@@ -247,6 +256,7 @@ private:
|
|||||||
GachaProgress gacha_;
|
GachaProgress gacha_;
|
||||||
std::map<uint32_t, ChallengeRecord> challengeRecords_;
|
std::map<uint32_t, ChallengeRecord> challengeRecords_;
|
||||||
std::map<uint32_t, uint64_t> challengeRewardsTaken_;
|
std::map<uint32_t, uint64_t> challengeRewardsTaken_;
|
||||||
|
std::map<uint32_t, GoodsPurchase> goodsPurchases_;
|
||||||
|
|
||||||
uint32_t mainCharacter_ = 8008;
|
uint32_t mainCharacter_ = 8008;
|
||||||
uint32_t marchType_ = 1224;
|
uint32_t marchType_ = 1224;
|
||||||
|
|||||||
@@ -243,6 +243,16 @@ bool loadPlayerState(Player& player) {
|
|||||||
player.challengeRewardsTaken()[group] = stars->get<uint64_t>();
|
player.challengeRewardsTaken()[group] = stars->get<uint64_t>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auto bought = j.find("goods_purchases"); bought != j.end() && bought->is_array()) {
|
||||||
|
for (const json& entry : *bought) {
|
||||||
|
uint32_t goods = entry.is_object() ? u32(entry, "goods", 0) : 0;
|
||||||
|
if (goods == 0) continue;
|
||||||
|
GoodsPurchase& purchase = player.goodsPurchases()[goods];
|
||||||
|
purchase.times = u32(entry, "times", 0);
|
||||||
|
purchase.period = i64(entry, "period", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +369,13 @@ std::string playerStateJson(const Player& player) {
|
|||||||
}
|
}
|
||||||
j["challenge_rewards_taken"] = taken;
|
j["challenge_rewards_taken"] = taken;
|
||||||
|
|
||||||
|
json bought = json::array();
|
||||||
|
for (const auto& [goods, purchase] : player.goodsPurchases()) {
|
||||||
|
if (purchase.times == 0) continue;
|
||||||
|
bought.push_back({{"goods", goods}, {"times", purchase.times}, {"period", purchase.period}});
|
||||||
|
}
|
||||||
|
j["goods_purchases"] = bought;
|
||||||
|
|
||||||
return j.dump(2);
|
return j.dump(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#include "game/shop.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <ctime>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
#include "core/logger.h"
|
||||||
|
#include "game/handlers.h"
|
||||||
|
#include "game/inventory.h"
|
||||||
|
#include "game/player.h"
|
||||||
|
|
||||||
|
namespace game::shop {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 2100-01-01: a shelf that never closes.
|
||||||
|
constexpr int64_t kForever = 4102444800;
|
||||||
|
|
||||||
|
std::tm localTime(int64_t seconds) {
|
||||||
|
std::time_t t = static_cast<std::time_t>(seconds);
|
||||||
|
std::tm tm{};
|
||||||
|
#ifdef _WIN32
|
||||||
|
localtime_s(&tm, &t);
|
||||||
|
#else
|
||||||
|
localtime_r(&t, &tm);
|
||||||
|
#endif
|
||||||
|
return tm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light cones, relics and characters live in the srtools build, which a purchase must not
|
||||||
|
// rewrite, so only what the bag holds is sold.
|
||||||
|
bool sellable(uint32_t itemId) {
|
||||||
|
const data::ItemInfo* item = data::Tables::get().item(itemId);
|
||||||
|
return item != nullptr &&
|
||||||
|
(item->mainType == "Material" || item->mainType == "Usable" || item->mainType == "Virtual");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int64_t periodOf(data::GoodsRefresh refresh, int64_t now) {
|
||||||
|
if (refresh == data::GoodsRefresh::Never) return 0;
|
||||||
|
std::tm tm = localTime(now);
|
||||||
|
if (refresh == data::GoodsRefresh::Weekly) {
|
||||||
|
// Back to Monday; mktime carries the day across month and year ends.
|
||||||
|
tm.tm_mday -= (tm.tm_wday + 6) % 7;
|
||||||
|
tm.tm_hour = 12;
|
||||||
|
tm.tm_isdst = -1;
|
||||||
|
std::mktime(&tm);
|
||||||
|
}
|
||||||
|
int64_t date = int64_t{tm.tm_year + 1900} * 10000 + (tm.tm_mon + 1) * 100 + tm.tm_mday;
|
||||||
|
return refresh == data::GoodsRefresh::Monthly ? date / 100 : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t boughtTimes(const Player& player, const data::GoodsInfo& goods, int64_t now) {
|
||||||
|
auto it = player.goodsPurchases().find(goods.id);
|
||||||
|
if (it == player.goodsPurchases().end()) return 0;
|
||||||
|
return it->second.period == periodOf(goods.refresh, now) ? it->second.times : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
proto::GetShopListScRsp list(const Player& player, uint32_t shopType, int64_t now) {
|
||||||
|
const data::Tables& tables = data::Tables::get();
|
||||||
|
proto::GetShopListScRsp rsp;
|
||||||
|
rsp.retcode = 0;
|
||||||
|
rsp.shop_type = shopType;
|
||||||
|
for (const data::ShopInfo* info : tables.shopsOfType(shopType)) {
|
||||||
|
proto::Shop shop;
|
||||||
|
shop.shop_id = info->id;
|
||||||
|
shop.city_level = 1;
|
||||||
|
shop.end_time = kForever;
|
||||||
|
for (uint32_t goodsId : info->goods) {
|
||||||
|
const data::GoodsInfo* goods = tables.goods(goodsId);
|
||||||
|
if (goods == nullptr) continue;
|
||||||
|
proto::Goods entry;
|
||||||
|
entry.goods_id = goods->id;
|
||||||
|
entry.item_id = goods->itemId;
|
||||||
|
entry.buy_times = boughtTimes(player, *goods, now);
|
||||||
|
entry.end_time = kForever;
|
||||||
|
shop.goods_list.push_back(entry);
|
||||||
|
}
|
||||||
|
rsp.shop_list.push_back(std::move(shop));
|
||||||
|
}
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
|
||||||
|
proto::BuyGoodsScRsp buy(Player& player, const proto::BuyGoodsCsReq& req, int64_t now,
|
||||||
|
std::vector<data::ItemStack>& changed) {
|
||||||
|
const data::Tables& tables = data::Tables::get();
|
||||||
|
changed.clear();
|
||||||
|
proto::BuyGoodsScRsp rsp;
|
||||||
|
rsp.shop_id = req.shop_id;
|
||||||
|
rsp.goods_id = req.goods_id;
|
||||||
|
|
||||||
|
const data::GoodsInfo* goods = tables.goods(req.goods_id);
|
||||||
|
if (goods == nullptr || goods->shopId != req.shop_id || !sellable(goods->itemId)) {
|
||||||
|
logging::info("shop", "goods {} of shop {} is not on sale here", req.goods_id, req.shop_id);
|
||||||
|
rsp.retcode = fail(proto::Retcode::RET_GOODS_NOT_OPEN);
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t count = std::max(1u, req.goods_num);
|
||||||
|
uint32_t bought = boughtTimes(player, *goods, now);
|
||||||
|
rsp.goods_buy_times = bought;
|
||||||
|
if (goods->limitTimes != 0 && bought + count > goods->limitTimes) {
|
||||||
|
logging::info("shop", "goods {} is limited to {} per period, {} already bought", goods->id,
|
||||||
|
goods->limitTimes, bought);
|
||||||
|
rsp.retcode = fail(proto::Retcode::RET_BUY_TIMES_LIMIT);
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr uint64_t kMax = std::numeric_limits<uint32_t>::max();
|
||||||
|
std::vector<data::ItemStack> price;
|
||||||
|
for (const data::ItemStack& cost : goods->cost) {
|
||||||
|
if (cost.num * count > kMax) {
|
||||||
|
rsp.retcode = fail(proto::Retcode::RET_ITEM_COST_TOO_MUCH);
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
price.push_back({cost.id, static_cast<uint32_t>(cost.num * count)});
|
||||||
|
}
|
||||||
|
if (goods->itemCount * count > kMax) {
|
||||||
|
rsp.retcode = fail(proto::Retcode::RET_ITEM_COST_TOO_MUCH);
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
if (!inventory::spend(player, price)) {
|
||||||
|
for (const data::ItemStack& cost : price) {
|
||||||
|
logging::info("shop", "goods {} costs {} x item {}, {} held -- /give {} to buy it", goods->id,
|
||||||
|
cost.num, cost.id, inventory::held(player, cost.id), cost.id);
|
||||||
|
}
|
||||||
|
rsp.retcode = fail(proto::Retcode::RET_ITEM_NOT_ENOUGH);
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<data::ItemStack> items{{goods->itemId, static_cast<uint32_t>(goods->itemCount * count)}};
|
||||||
|
inventory::grant(player, items);
|
||||||
|
if (goods->limitTimes != 0) {
|
||||||
|
GoodsPurchase& purchase = player.goodsPurchases()[goods->id];
|
||||||
|
purchase.period = periodOf(goods->refresh, now);
|
||||||
|
purchase.times = bought + static_cast<uint32_t>(count);
|
||||||
|
rsp.goods_buy_times = purchase.times;
|
||||||
|
}
|
||||||
|
|
||||||
|
rsp.retcode = 0;
|
||||||
|
rsp.return_item_list = inventory::itemList(items);
|
||||||
|
changed = price;
|
||||||
|
changed.insert(changed.end(), items.begin(), items.end());
|
||||||
|
return rsp;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace game::shop
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data/excel.h"
|
||||||
|
#include "proto/gen/protos.h"
|
||||||
|
|
||||||
|
namespace game {
|
||||||
|
|
||||||
|
class Player;
|
||||||
|
|
||||||
|
// The in-game shops: ShopConfig's shelves, stocked from ShopGoodsConfig.
|
||||||
|
namespace shop {
|
||||||
|
|
||||||
|
// The refresh period `now` falls in, in local time: yyyymmdd for a day or for the Monday
|
||||||
|
// that starts a week, yyyymm for a month. Goods that never refresh are always in 0.
|
||||||
|
int64_t periodOf(data::GoodsRefresh refresh, int64_t now);
|
||||||
|
|
||||||
|
// How many of `goods` were bought in the current period.
|
||||||
|
uint32_t boughtTimes(const Player& player, const data::GoodsInfo& goods, int64_t now);
|
||||||
|
|
||||||
|
proto::GetShopListScRsp list(const Player& player, uint32_t shopType, int64_t now);
|
||||||
|
|
||||||
|
// Checks the limit and the price, takes the price and hands the goods over. `changed`
|
||||||
|
// gets every item whose count moved, for the sync.
|
||||||
|
proto::BuyGoodsScRsp buy(Player& player, const proto::BuyGoodsCsReq& req, int64_t now,
|
||||||
|
std::vector<data::ItemStack>& changed);
|
||||||
|
|
||||||
|
} // namespace shop
|
||||||
|
} // namespace game
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#include "core/logger.h"
|
||||||
|
#include "core/util.h"
|
||||||
|
#include "game/handlers.h"
|
||||||
|
#include "game/inventory.h"
|
||||||
|
#include "game/player.h"
|
||||||
|
#include "game/shop.h"
|
||||||
|
#include "net/cmd_ids.h"
|
||||||
|
#include "net/handler.h"
|
||||||
|
#include "net/session.h"
|
||||||
|
#include "proto/gen/protos.h"
|
||||||
|
|
||||||
|
namespace game {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Player* playerOf(net::Session& session, const char* what) {
|
||||||
|
Player* player = session.player();
|
||||||
|
if (player == nullptr) logging::warn("game", "{} before login", what);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t now() { return static_cast<int64_t>(util::nowSec()); }
|
||||||
|
|
||||||
|
void onGetShopList(net::Session& session, const proto::GetShopListCsReq& req) {
|
||||||
|
Player* player = playerOf(session, "GetShopList");
|
||||||
|
if (player == nullptr) return;
|
||||||
|
session.send(cmd::GetShopListScRsp, shop::list(*player, req.shop_type, now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void onBuyGoods(net::Session& session, const proto::BuyGoodsCsReq& req) {
|
||||||
|
Player* player = playerOf(session, "BuyGoods");
|
||||||
|
if (player == nullptr) return;
|
||||||
|
|
||||||
|
std::vector<data::ItemStack> changed;
|
||||||
|
proto::BuyGoodsScRsp rsp = shop::buy(*player, req, now(), changed);
|
||||||
|
if (rsp.retcode == 0) {
|
||||||
|
session.send(cmd::PlayerSyncScNotify, inventory::sync(*player, changed));
|
||||||
|
player->saveNow();
|
||||||
|
}
|
||||||
|
session.send(cmd::BuyGoodsScRsp, rsp);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void registerShopHandlers() {
|
||||||
|
net::on<proto::GetShopListCsReq>(cmd::GetShopListCsReq, onGetShopList);
|
||||||
|
net::on<proto::BuyGoodsCsReq>(cmd::BuyGoodsCsReq, onBuyGoods);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace game
|
||||||
@@ -1122,6 +1122,15 @@ void runFlowTests() {
|
|||||||
claimed.retcode == 0 && !claimed.taken_reward_list.empty(),
|
claimed.retcode == 0 && !claimed.taken_reward_list.empty(),
|
||||||
"season rewards can be claimed");
|
"season rewards can be claimed");
|
||||||
|
|
||||||
|
// Shops.
|
||||||
|
proto::GetShopListCsReq shops;
|
||||||
|
shops.shop_type = 1;
|
||||||
|
client.send(cmd::GetShopListCsReq, shops);
|
||||||
|
proto::GetShopListScRsp shopList;
|
||||||
|
check(client.await(cmd::GetShopListScRsp, packet) && parseBody(packet, shopList) && shopList.retcode == 0 &&
|
||||||
|
!shopList.shop_list.empty() && !shopList.shop_list[0].goods_list.empty(),
|
||||||
|
"a shop lists its goods");
|
||||||
|
|
||||||
// An unimplemented request still has to complete, or the client hangs on it. This
|
// An unimplemented request still has to complete, or the client hangs on it. This
|
||||||
// one has no handler at all, so it exercises the name-derived fallback.
|
// one has no handler at all, so it exercises the name-derived fallback.
|
||||||
client.sendEmpty(cmd::GetStarFightDataCsReq);
|
client.sendEmpty(cmd::GetStarFightDataCsReq);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// present; the data-driven checks are skipped (and reported) when they are not.
|
// present; the data-driven checks are skipped (and reported) when they are not.
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
#include "game/player_store.h"
|
#include "game/player_store.h"
|
||||||
#include "game/roster.h"
|
#include "game/roster.h"
|
||||||
#include "game/scene.h"
|
#include "game/scene.h"
|
||||||
|
#include "game/shop.h"
|
||||||
#include "game/srtools.h"
|
#include "game/srtools.h"
|
||||||
#include "game/tierce.h"
|
#include "game/tierce.h"
|
||||||
#include "net/packet.h"
|
#include "net/packet.h"
|
||||||
@@ -541,6 +543,142 @@ void testInventory() {
|
|||||||
core::Config::get().paths.playerFile = realPath;
|
core::Config::get().paths.playerFile = realPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testShop() {
|
||||||
|
const data::Tables& tables = data::Tables::get();
|
||||||
|
if (!tables.loaded()) {
|
||||||
|
std::printf("SKIP shop (no data source configured)\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
namespace inventory = game::inventory;
|
||||||
|
namespace shop = game::shop;
|
||||||
|
using data::GoodsRefresh;
|
||||||
|
|
||||||
|
// Noon, local time. 2026-09-14 is a Monday.
|
||||||
|
auto at = [](int year, int month, int day) {
|
||||||
|
std::tm tm{};
|
||||||
|
tm.tm_year = year - 1900;
|
||||||
|
tm.tm_mon = month - 1;
|
||||||
|
tm.tm_mday = day;
|
||||||
|
tm.tm_hour = 12;
|
||||||
|
tm.tm_isdst = -1;
|
||||||
|
return static_cast<int64_t>(std::mktime(&tm));
|
||||||
|
};
|
||||||
|
int64_t monday = at(2026, 9, 14);
|
||||||
|
int64_t sunday = at(2026, 9, 20);
|
||||||
|
int64_t nextMonday = at(2026, 9, 21);
|
||||||
|
check(shop::periodOf(GoodsRefresh::Daily, monday) == 20260914 &&
|
||||||
|
shop::periodOf(GoodsRefresh::Monthly, sunday) == 202609 &&
|
||||||
|
shop::periodOf(GoodsRefresh::Never, monday) == 0,
|
||||||
|
"goods refresh by day and by month, or never");
|
||||||
|
check(shop::periodOf(GoodsRefresh::Weekly, sunday) == 20260914 &&
|
||||||
|
shop::periodOf(GoodsRefresh::Weekly, nextMonday) == 20260921 &&
|
||||||
|
shop::periodOf(GoodsRefresh::Weekly, at(2026, 10, 2)) == 20260928,
|
||||||
|
"and by the week, from Monday, across a month end");
|
||||||
|
|
||||||
|
// A material sold for credits, a few at a time.
|
||||||
|
const data::GoodsInfo* pick = nullptr;
|
||||||
|
const data::GoodsInfo* locked = nullptr;
|
||||||
|
for (const auto& [shopId, info] : tables.shops()) {
|
||||||
|
for (uint32_t goodsId : info.goods) {
|
||||||
|
const data::GoodsInfo* goods = tables.goods(goodsId);
|
||||||
|
const data::ItemInfo* item = goods != nullptr ? tables.item(goods->itemId) : nullptr;
|
||||||
|
if (goods == nullptr) continue;
|
||||||
|
if (item == nullptr && (locked == nullptr || goods->id < locked->id)) locked = goods;
|
||||||
|
bool credits = goods->cost.size() == 1 && goods->cost[0].id == inventory::kCredit;
|
||||||
|
if (item != nullptr && item->mainType == "Material" && credits && goods->limitTimes >= 2 &&
|
||||||
|
goods->itemCount * goods->limitTimes <= 999 && (pick == nullptr || goods->id < pick->id)) {
|
||||||
|
pick = goods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(pick != nullptr && locked != nullptr, "the shops stock materials for credits, and gear");
|
||||||
|
if (pick == nullptr || locked == nullptr) return;
|
||||||
|
|
||||||
|
// Rotating stock is what a client on another patch has no row for: its shop module
|
||||||
|
// throws part way through building the shelf and the rest of that shop is lost. So
|
||||||
|
// nothing paid for with Oneiric Shards, and no recharge page, ever goes out.
|
||||||
|
size_t paid = 0;
|
||||||
|
for (const auto& [shopId, info] : tables.shops()) {
|
||||||
|
for (uint32_t goodsId : info.goods) {
|
||||||
|
const data::GoodsInfo* goods = tables.goods(goodsId);
|
||||||
|
if (goods == nullptr) continue;
|
||||||
|
for (const data::ItemStack& cost : goods->cost) {
|
||||||
|
if (cost.id == inventory::kOneiricShard) ++paid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(paid == 0, "and never real-money bundles, which rotate every patch");
|
||||||
|
|
||||||
|
game::Player player(1);
|
||||||
|
player.inventory().scoin = 0;
|
||||||
|
std::vector<data::ItemStack> changed;
|
||||||
|
proto::BuyGoodsCsReq req;
|
||||||
|
req.shop_id = pick->shopId;
|
||||||
|
req.goods_id = pick->id;
|
||||||
|
req.goods_num = pick->limitTimes;
|
||||||
|
check(shop::buy(player, req, monday, changed).retcode ==
|
||||||
|
static_cast<uint32_t>(proto::Retcode::RET_ITEM_NOT_ENOUGH) &&
|
||||||
|
inventory::held(player, pick->itemId) == 0 && changed.empty(),
|
||||||
|
"without the credits nothing is bought");
|
||||||
|
|
||||||
|
uint32_t price = pick->cost[0].num * pick->limitTimes;
|
||||||
|
player.inventory().scoin = price;
|
||||||
|
proto::BuyGoodsScRsp bought = shop::buy(player, req, monday, changed);
|
||||||
|
check(bought.retcode == 0 && bought.goods_buy_times == pick->limitTimes && player.inventory().scoin == 0,
|
||||||
|
"buying up to the limit takes the whole price");
|
||||||
|
check(inventory::held(player, pick->itemId) == uint64_t{pick->itemCount} * pick->limitTimes &&
|
||||||
|
bought.return_item_list.has() && changed.size() == 2,
|
||||||
|
"and puts the goods in the bag");
|
||||||
|
|
||||||
|
proto::GetShopListScRsp listed = shop::list(player, tables.shop(pick->shopId)->type, monday);
|
||||||
|
uint32_t shown = 0;
|
||||||
|
for (const proto::Shop& entry : listed.shop_list) {
|
||||||
|
for (const proto::Goods& goods : entry.goods_list) {
|
||||||
|
if (goods.goods_id == pick->id) shown = goods.buy_times;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(shown == pick->limitTimes, "the shelf shows how many were bought");
|
||||||
|
|
||||||
|
req.goods_num = 1;
|
||||||
|
player.inventory().scoin = pick->cost[0].num;
|
||||||
|
check(shop::buy(player, req, monday, changed).retcode ==
|
||||||
|
static_cast<uint32_t>(proto::Retcode::RET_BUY_TIMES_LIMIT),
|
||||||
|
"one more is over the limit");
|
||||||
|
if (pick->refresh != GoodsRefresh::Never) {
|
||||||
|
int64_t later = pick->refresh == GoodsRefresh::Daily ? at(2026, 9, 15)
|
||||||
|
: pick->refresh == GoodsRefresh::Weekly ? nextMonday
|
||||||
|
: at(2026, 10, 1);
|
||||||
|
check(shop::buy(player, req, later, changed).retcode == 0, "until the next period");
|
||||||
|
}
|
||||||
|
|
||||||
|
proto::BuyGoodsCsReq gear;
|
||||||
|
gear.shop_id = locked->shopId;
|
||||||
|
gear.goods_id = locked->id;
|
||||||
|
gear.goods_num = 1;
|
||||||
|
player.inventory().hcoin = 1000000;
|
||||||
|
player.inventory().scoin = 1000000;
|
||||||
|
player.inventory().mcoin = 1000000;
|
||||||
|
check(shop::buy(player, gear, monday, changed).retcode ==
|
||||||
|
static_cast<uint32_t>(proto::Retcode::RET_GOODS_NOT_OPEN) &&
|
||||||
|
player.inventory().hcoin == 1000000 && player.inventory().scoin == 1000000,
|
||||||
|
"gear the srtools build keeps is not sold");
|
||||||
|
|
||||||
|
player.inventory().items = {{101, 5}};
|
||||||
|
check(!inventory::spend(player, {{101, 3}, {inventory::kCredit, 2000000}}) &&
|
||||||
|
player.inventory().items[101] == 5,
|
||||||
|
"spending is all or nothing");
|
||||||
|
check(inventory::spend(player, {{101, 5}}) && player.inventory().items.count(101) == 0,
|
||||||
|
"and an emptied stack leaves the bag");
|
||||||
|
|
||||||
|
std::string realPath = core::Config::get().paths.playerFile;
|
||||||
|
core::Config::get().paths.playerFile = "build/test-shop-player.json";
|
||||||
|
game::savePlayerState(player);
|
||||||
|
game::Player loaded(1);
|
||||||
|
game::loadPlayerState(loaded);
|
||||||
|
check(loaded.goodsPurchases().count(pick->id) == 1, "purchases survive a restart");
|
||||||
|
core::Config::get().paths.playerFile = realPath;
|
||||||
|
}
|
||||||
|
|
||||||
void testChallengeHistory() {
|
void testChallengeHistory() {
|
||||||
const data::Tables& tables = data::Tables::get();
|
const data::Tables& tables = data::Tables::get();
|
||||||
if (!tables.loaded()) return;
|
if (!tables.loaded()) return;
|
||||||
@@ -1175,6 +1313,7 @@ void runGameTests() {
|
|||||||
testTables();
|
testTables();
|
||||||
testGacha();
|
testGacha();
|
||||||
testInventory();
|
testInventory();
|
||||||
|
testShop();
|
||||||
testChallengeHistory();
|
testChallengeHistory();
|
||||||
testSceneRes();
|
testSceneRes();
|
||||||
testRosterProtos();
|
testRosterProtos();
|
||||||
|
|||||||
Reference in new issue
Block a user