feat(game): sell goods in the shops

This commit is contained in:
capyb2222 committed 2026-09-13 10:24:45 -07:00
1 parent 60a20b7c7c
commit a6d15b6262
12 files changed
+447

No files matched your search

+4
View File
@@ -251,6 +251,10 @@ ChallengePeakSettleScNotify
# ---- inventory ---- # ---- inventory ----
GetBagCsReq GetBagCsReq
GetBagScRsp GetBagScRsp
GetShopListCsReq
GetShopListScRsp
BuyGoodsCsReq
BuyGoodsScRsp
UseItemCsReq UseItemCsReq
UseItemScRsp UseItemScRsp
SellItemCsReq SellItemCsReq
+1
View File
@@ -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();
+35
View File
@@ -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();
+4
View File
@@ -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.
+1
View File
@@ -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",
+10
View File
@@ -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;
+17
View File
@@ -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);
} }
+147
View File
@@ -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
+31
View File
@@ -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
+49
View File
@@ -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
+9
View File
@@ -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);
+139
View File
@@ -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();