diff --git a/proto/seeds.txt b/proto/seeds.txt index 16e726d..1c49140 100644 --- a/proto/seeds.txt +++ b/proto/seeds.txt @@ -251,6 +251,10 @@ ChallengePeakSettleScNotify # ---- inventory ---- GetBagCsReq GetBagScRsp +GetShopListCsReq +GetShopListScRsp +BuyGoodsCsReq +BuyGoodsScRsp UseItemCsReq UseItemScRsp SellItemCsReq diff --git a/src/game/handlers.h b/src/game/handlers.h index 3b72e07..e9d4363 100644 --- a/src/game/handlers.h +++ b/src/game/handlers.h @@ -24,6 +24,7 @@ void registerChallengeHandlers(); void registerTierceHandlers(); void registerPeakHandlers(); void registerGachaHandlers(); +void registerShopHandlers(); void registerModuleHandlers(); void registerMiscHandlers(); diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 7ca47c5..542ae38 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -123,6 +123,41 @@ void grant(Player& player, const std::vector& 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& items) { + std::vector 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) { if (mappingInfoId == 0) return nullptr; const data::Tables& tables = data::Tables::get(); diff --git a/src/game/inventory.h b/src/game/inventory.h index 915d0fd..5ed04b1 100644 --- a/src/game/inventory.h +++ b/src/game/inventory.h @@ -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. // Trailblaze EXP has nowhere to go while the level is fixed. void grant(Player& player, const std::vector& 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& items); // A farming spot's drop table at `worldLevel`, or at the nearest world level that lists // any drops: the tables leave some levels empty. diff --git a/src/game/misc_handlers.cpp b/src/game/misc_handlers.cpp index d27efd6..2761318 100644 --- a/src/game/misc_handlers.cpp +++ b/src/game/misc_handlers.cpp @@ -207,6 +207,7 @@ void registerAllHandlers() { registerTierceHandlers(); registerPeakHandlers(); registerGachaHandlers(); + registerShopHandlers(); registerModuleHandlers(); registerMiscHandlers(); logging::info("game", "{} packet handlers registered, {} answered empty", diff --git a/src/game/player.h b/src/game/player.h index d26cff5..984d716 100644 --- a/src/game/player.h +++ b/src/game/player.h @@ -52,6 +52,12 @@ struct ChallengeRecord { std::vector 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 // run, which is what the real server does with an expired season anyway. struct ChallengeRun { @@ -206,6 +212,9 @@ public: // Season id -> bitmask over the star counts whose reward has been claimed. std::map& challengeRewardsTaken() { return challengeRewardsTaken_; } const std::map& challengeRewardsTaken() const { return challengeRewardsTaken_; } + // Keyed by goods id. + std::map& goodsPurchases() { return goodsPurchases_; } + const std::map& goodsPurchases() const { return goodsPurchases_; } uint32_t mainCharacter() const { return mainCharacter_; } void setMainCharacter(uint32_t value) { mainCharacter_ = value; } @@ -247,6 +256,7 @@ private: GachaProgress gacha_; std::map challengeRecords_; std::map challengeRewardsTaken_; + std::map goodsPurchases_; uint32_t mainCharacter_ = 8008; uint32_t marchType_ = 1224; diff --git a/src/game/player_store.cpp b/src/game/player_store.cpp index 95116c1..f777e36 100644 --- a/src/game/player_store.cpp +++ b/src/game/player_store.cpp @@ -243,6 +243,16 @@ bool loadPlayerState(Player& player) { player.challengeRewardsTaken()[group] = stars->get(); } } + + 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; } @@ -359,6 +369,13 @@ std::string playerStateJson(const Player& player) { } 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); } diff --git a/src/game/shop.cpp b/src/game/shop.cpp new file mode 100644 index 0000000..f2e2102 --- /dev/null +++ b/src/game/shop.cpp @@ -0,0 +1,147 @@ +#include "game/shop.h" + +#include +#include +#include + +#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(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& 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::max(); + std::vector 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(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 items{{goods->itemId, static_cast(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(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 diff --git a/src/game/shop.h b/src/game/shop.h new file mode 100644 index 0000000..e4fe91c --- /dev/null +++ b/src/game/shop.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#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& changed); + +} // namespace shop +} // namespace game diff --git a/src/game/shop_handlers.cpp b/src/game/shop_handlers.cpp new file mode 100644 index 0000000..25a8192 --- /dev/null +++ b/src/game/shop_handlers.cpp @@ -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(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 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(cmd::GetShopListCsReq, onGetShopList); + net::on(cmd::BuyGoodsCsReq, onBuyGoods); +} + +} // namespace game diff --git a/tests/test_flow.cpp b/tests/test_flow.cpp index d5053be..29f931e 100644 --- a/tests/test_flow.cpp +++ b/tests/test_flow.cpp @@ -1122,6 +1122,15 @@ void runFlowTests() { claimed.retcode == 0 && !claimed.taken_reward_list.empty(), "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 // one has no handler at all, so it exercises the name-derived fallback. client.sendEmpty(cmd::GetStarFightDataCsReq); diff --git a/tests/test_game.cpp b/tests/test_game.cpp index 9bfbdba..3894656 100644 --- a/tests/test_game.cpp +++ b/tests/test_game.cpp @@ -2,6 +2,7 @@ // present; the data-driven checks are skipped (and reported) when they are not. #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include "game/player_store.h" #include "game/roster.h" #include "game/scene.h" +#include "game/shop.h" #include "game/srtools.h" #include "game/tierce.h" #include "net/packet.h" @@ -541,6 +543,142 @@ void testInventory() { 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(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 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(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(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(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() { const data::Tables& tables = data::Tables::get(); if (!tables.loaded()) return; @@ -1175,6 +1313,7 @@ void runGameTests() { testTables(); testGacha(); testInventory(); + testShop(); testChallengeHistory(); testSceneRes(); testRosterProtos();