feat(game): add stamina, drops and sweeps

This commit is contained in:
capyb2222 committed 2026-09-12 22:04:20 -07:00
1 parent 5b58096dcf
commit ab75080134
12 files changed
+744 -41

No files matched your search

+36 -1
View File
@@ -3,6 +3,7 @@
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <string>
#include <thread>
#include <vector>
@@ -211,8 +212,11 @@ bool parseBody(const net::Packet& packet, T& out) {
} // namespace
void runFlowTests() {
// Keep the test off the real save file.
// Keep the test off the real save file, and start it fresh: stamina and claimed
// rewards carry over between runs otherwise.
core::Config::get().paths.playerFile = "build/test-player.json";
std::error_code ec;
std::filesystem::remove(core::Config::get().paths.playerFile, ec);
game::registerAllHandlers();
net::Gateway gateway;
@@ -1061,6 +1065,37 @@ void runFlowTests() {
"a custom 50/50 pool is refused with a body");
core::Config::get().gameplay.limitedWarpPools = configuredPools;
// Stamina, farming and the bag.
client.sendEmpty(cmd::ExchangeStaminaCsReq);
proto::ExchangeStaminaScRsp exchange;
check(client.await(cmd::ExchangeStaminaScRsp, packet) && parseBody(packet, exchange) &&
exchange.retcode == 0 && exchange.stamina_add == 60 && exchange.item_cost_list.size() == 1,
"stamina can be bought with jade");
proto::CocoonSweepCsReq sweep;
sweep.cocoon_id = 1001;
sweep.world_level = 6;
client.send(cmd::CocoonSweepCsReq, sweep);
proto::CocoonSweepScRsp swept;
check(client.await(cmd::CocoonSweepScRsp, packet) && parseBody(packet, swept) && swept.retcode == 0 &&
swept.NCEFJFOHGBI > 0 && swept.multiple_drop_data.has() &&
!swept.multiple_drop_data->item_list.empty(),
"a calyx sweep spends the stamina and drops items");
check(client.sawNotify(cmd::StaminaInfoScNotify) && client.sawNotify(cmd::PlayerSyncScNotify),
"and tells the client about both");
client.send(cmd::CocoonSweepCsReq, sweep);
proto::CocoonSweepScRsp dry;
check(client.await(cmd::CocoonSweepScRsp, packet) && parseBody(packet, dry) &&
dry.retcode == static_cast<uint32_t>(proto::Retcode::RET_LACK_STAMINA),
"with no stamina left the next sweep is refused");
client.sendEmpty(cmd::GetBagCsReq);
proto::GetBagScRsp bagAfter;
check(client.await(cmd::GetBagScRsp, packet) && parseBody(packet, bagAfter) &&
bagAfter.material_list.size() > 2,
"the drops are in the bag");
// 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);
+89
View File
@@ -17,6 +17,7 @@
#include "game/battle.h"
#include "game/challenge.h"
#include "game/gacha.h"
#include "game/inventory.h"
#include "game/lineup.h"
#include "game/player.h"
#include "game/player_store.h"
@@ -413,6 +414,93 @@ void testGacha() {
core::Config::get().paths.playerFile = realPath;
}
void testInventory() {
const data::Tables& tables = data::Tables::get();
if (!tables.loaded()) {
std::printf("SKIP inventory (no data source configured)\n");
return;
}
namespace inventory = game::inventory;
const data::StaminaRules& rules = tables.staminaRules();
check(rules.max == 300 && rules.recoverSeconds == 360 && rules.reserveMax == 2400 &&
rules.reserveRecoverSeconds == 1080,
"stamina rules come from the tables");
check(tables.staminaPrices().size() == 8 && tables.staminaPrices()[0] == 50 &&
tables.staminaPerPurchase() == 60,
"and the stamina prices");
game::Player player(1);
game::Inventory& bag = player.inventory();
bag.stamina = 100;
bag.staminaUpdatedAt = 1000;
inventory::refreshStamina(player, 1000 + 360 * 5 + 10);
check(bag.stamina == 105 && bag.staminaUpdatedAt == 1000 + 360 * 5,
"stamina recovers a point every six minutes");
check(inventory::nextRecoverTime(player) == 1000 + 360 * 6, "and says when the next is due");
bag.stamina = 299;
bag.staminaUpdatedAt = 5000;
bag.reserveStamina = 0;
inventory::refreshStamina(player, 5000 + 360 + 1080 * 2);
check(bag.stamina == 300 && bag.reserveStamina == 2, "once full, time runs into reserve stamina");
check(inventory::nextRecoverTime(player) == 0, "and nothing more is due");
inventory::chargeStamina(player, 40, 9000);
check(bag.stamina == 260 && bag.staminaUpdatedAt == 9000, "spending from full starts the clock");
uint32_t jade = bag.hcoin;
uint32_t credits = bag.scoin;
inventory::grant(player, {{1, 50}, {2, 1000}, {211, 3}, {22, 150}, {11, 60}});
check(bag.hcoin == jade + 50 && bag.scoin == credits + 1000, "jade and credits go to the wallet");
check(bag.items[211] == 3 && bag.stamina == 320, "materials to the bag, stamina to stamina");
check(bag.items.count(22) == 0, "and trailblaze exp nowhere");
const data::MappingInfo* calyx = inventory::dropTable(1001, 6);
check(calyx != nullptr && !calyx->display.empty(), "a calyx has a drop table at world level 6");
if (calyx != nullptr) {
bool credit = false;
for (const data::ItemStack& drop : inventory::rollDrops(*calyx, [] { return 0.5; })) {
credit |= drop.id == 2 && drop.num > 0;
}
check(credit, "and a run drops credits");
}
const data::MappingInfo* shadow = inventory::dropTable(1101, 6);
check(shadow != nullptr && shadow->worldLevel == 5, "a shadow's empty level falls back a level");
if (shadow != nullptr) {
bool boss = false;
for (const data::ItemStack& drop : inventory::rollDrops(*shadow, [] { return 0.5; })) {
boss |= drop.id == 110406 && drop.num == 5;
}
check(boss, "and its boss material scales with the world level");
}
check(inventory::dropTable(0, 6) == nullptr, "no table, no drops");
const data::RewardInfo* reward = tables.reward(101101);
check(reward != nullptr, "RewardData loads");
if (reward != nullptr) {
std::vector<data::ItemStack> items = inventory::rewardItems(*reward);
check(items.size() == 2 && items[0].id == 1 && items[0].num == 200 && items[1].id == 2 &&
items[1].num == 20000,
"a reward carries its jade and items");
}
std::vector<data::ItemStack> merged = inventory::merged({{2, 10}, {211, 1}, {2, 5}});
check(merged.size() == 2 && merged[0].num == 15, "repeats merge");
proto::PlayerSyncScNotify sync = inventory::sync(player, {{211, 3}, {1, 50}});
check(sync.material_list.size() == 1 && sync.material_list[0].tid == 211 &&
sync.material_list[0].num == 3 && sync.basic_info.has(),
"a sync names bag totals and carries the wallet");
std::string realPath = core::Config::get().paths.playerFile;
core::Config::get().paths.playerFile = "build/test-inventory-player.json";
check(game::savePlayerState(player), "the inventory saves");
game::Player loaded(1);
check(game::loadPlayerState(loaded), "and loads");
check(loaded.inventory().items[211] == 3 && loaded.inventory().reserveStamina == bag.reserveStamina,
"the bag survives a restart");
core::Config::get().paths.playerFile = realPath;
}
void testChallengeHistory() {
const data::Tables& tables = data::Tables::get();
if (!tables.loaded()) return;
@@ -1046,6 +1134,7 @@ void runGameTests() {
testElementBuffs();
testTables();
testGacha();
testInventory();
testChallengeHistory();
testSceneRes();
testRosterProtos();