diff --git a/src/game/battle.cpp b/src/game/battle.cpp index d6b2ce8..6f361c3 100644 --- a/src/game/battle.cpp +++ b/src/game/battle.cpp @@ -173,7 +173,8 @@ proto::SceneBattleInfo create(Player& player, const BattleRequest& request) { } // ---- what they are fighting ---------------------------------------------- - if (useSrToolsBattle(config, request.allowSrToolsOverride)) { + bool srtools = useSrToolsBattle(config, request.allowSrToolsOverride); + if (srtools) { info.stage_id = config.stageId; info.rounds_limit = config.cycleCount; // A calyx sweep buys several runs, one fight each, so the build's waves repeat. @@ -289,11 +290,21 @@ proto::SceneBattleInfo create(Player& player, const BattleRequest& request) { context.cocoonId = request.cocoonId; context.wave = request.wave; context.monsterEntityIds = request.monsterEntityIds; + // A srtools build fighting in the calyx's place is not a farming run. + context.staminaCost = srtools ? 0 : request.staminaCost; + context.mappingInfoId = srtools ? 0 : request.mappingInfoId; + context.worldLevel = request.worldLevel != 0 ? request.worldLevel : player.worldLevel(); + context.runs = std::max(1u, request.wave); logging::debug("battle", "battle {} stage {} with {} wave(s) and {} buff(s)", info.battle_id, info.stage_id, info.monster_wave_list.size(), info.buff_list.size()); return info; } +bool srToolsTakesOver(const Player& player, const BattleRequest& request) { + Roster roster = player.roster(); + return useSrToolsBattle(roster.data().battle, request.allowSrToolsOverride); +} + } // namespace battle } // namespace game diff --git a/src/game/battle.h b/src/game/battle.h index 8413f40..88e8629 100644 --- a/src/game/battle.h +++ b/src/game/battle.h @@ -20,6 +20,10 @@ struct BattleRequest { std::vector stageIds; uint32_t cocoonId = 0; uint32_t wave = 0; + // A calyx or shadow run: what a win costs, and which drop table pays out. + uint32_t staminaCost = 0; + uint32_t mappingInfoId = 0; + uint32_t worldLevel = 0; // Only a calyx hands its fight over to a srtools build; everything else keeps the // stage it named. See gameplay.battle_source. bool allowSrToolsOverride = false; @@ -49,5 +53,8 @@ namespace battle { // player so PVEBattleResult can be answered. proto::SceneBattleInfo create(Player& player, const BattleRequest& request); +// Whether a srtools build takes this fight over, which makes it cost and pay nothing. +bool srToolsTakesOver(const Player& player, const BattleRequest& request); + } // namespace battle } // namespace game diff --git a/src/game/battle_handlers.cpp b/src/game/battle_handlers.cpp index f326058..5fddc63 100644 --- a/src/game/battle_handlers.cpp +++ b/src/game/battle_handlers.cpp @@ -6,6 +6,7 @@ #include "game/battle.h" #include "game/challenge.h" #include "game/handlers.h" +#include "game/inventory.h" #include "game/notify.h" #include "game/peak.h" #include "game/player.h" @@ -181,26 +182,57 @@ void onSceneCastSkillCostMp(net::Session& session, const proto::SceneCastSkillCo session.send(cmd::SceneCastSkillCostMpScRsp, rsp); } +int64_t now() { return static_cast(util::nowSec()); } + // Calyx: the one fight a srtools build is allowed to take over, monsters, blessings and // all. `wave` is how many runs were bought, one stage each. -proto::SceneBattleInfo cocoonBattle(Player& player, uint32_t cocoonId, uint32_t wave, - uint32_t worldLevel) { +BattleRequest cocoonRequest(uint32_t cocoonId, uint32_t wave, uint32_t worldLevel) { BattleRequest request; request.cocoonId = cocoonId; request.wave = std::max(1u, wave); request.allowSrToolsOverride = true; + request.worldLevel = worldLevel; const data::CocoonInfo* cocoon = data::Tables::get().cocoon(cocoonId, worldLevel); if (cocoon == nullptr || cocoon->stageIds.empty()) { logging::warn("battle", "cocoon {} has no stage at world level {}", cocoonId, worldLevel); - return battle::create(player, request); + return request; } + request.staminaCost = cocoon->staminaCost * request.wave; + request.mappingInfoId = cocoon->mappingInfoId; // Each run draws one of the calyx's stage variants, as the real server does. for (uint32_t run = 0; run < request.wave; ++run) { uint32_t pick = util::randomRange(0, static_cast(cocoon->stageIds.size()) - 1); request.stageIds.push_back(cocoon->stageIds[pick]); } - return battle::create(player, request); + return request; +} + +// A run the stamina does not cover is refused before it starts. A srtools build fighting +// in its place costs nothing. +bool affordable(Player& player, const BattleRequest& request) { + if (request.staminaCost == 0 || battle::srToolsTakesOver(player, request)) return true; + inventory::refreshStamina(player, now()); + return player.stamina() >= request.staminaCost; +} + +// Charges `cost`, rolls `runs` runs of the drop table into the bag and tells the client. +std::vector payOut(net::Session& session, Player& player, uint32_t mappingInfoId, + uint32_t worldLevel, uint32_t runs, uint32_t cost) { + std::vector drops; + if (const data::MappingInfo* table = inventory::dropTable(mappingInfoId, worldLevel)) { + for (uint32_t run = 0; run < runs; ++run) { + std::vector rolled = inventory::rollDrops(*table, gachaRoll); + drops.insert(drops.end(), rolled.begin(), rolled.end()); + } + } + drops = inventory::merged(drops); + inventory::chargeStamina(player, cost, now()); + inventory::grant(player, drops); + session.send(cmd::PlayerSyncScNotify, inventory::sync(player, drops)); + session.send(cmd::StaminaInfoScNotify, inventory::staminaInfo(player)); + player.saveNow(); + return drops; } void onStartCocoonStage(net::Session& session, const proto::StartCocoonStageCsReq& req) { @@ -208,11 +240,16 @@ void onStartCocoonStage(net::Session& session, const proto::StartCocoonStageCsRe if (player == nullptr) return; proto::StartCocoonStageScRsp rsp; - rsp.retcode = 0; rsp.cocoon_id = req.cocoon_id; rsp.prop_entity_id = req.prop_entity_id; rsp.wave = req.wave; - rsp.battle_info = cocoonBattle(*player, req.cocoon_id, req.wave, player->worldLevel()); + BattleRequest request = cocoonRequest(req.cocoon_id, req.wave, player->worldLevel()); + if (!affordable(*player, request)) { + rsp.retcode = fail(proto::Retcode::RET_LACK_STAMINA); + } else { + rsp.retcode = 0; + rsp.battle_info = battle::create(*player, request); + } session.send(cmd::StartCocoonStageScRsp, rsp); } @@ -223,12 +260,17 @@ void onQuickStartCocoonStage(net::Session& session, uint32_t worldLevel = req.world_level != 0 ? req.world_level : player->worldLevel(); proto::QuickStartCocoonStageScRsp rsp; - rsp.retcode = 0; rsp.cocoon_id = req.cocoon_id; rsp.wave = req.wave; - auto& info = rsp.battle_info.emplace(); - info = cocoonBattle(*player, req.cocoon_id, req.wave, worldLevel); - info.world_level = worldLevel; + BattleRequest request = cocoonRequest(req.cocoon_id, req.wave, worldLevel); + if (!affordable(*player, request)) { + rsp.retcode = fail(proto::Retcode::RET_LACK_STAMINA); + } else { + rsp.retcode = 0; + auto& info = rsp.battle_info.emplace(); + info = battle::create(*player, request); + info.world_level = worldLevel; + } session.send(cmd::QuickStartCocoonStageScRsp, rsp); } @@ -239,10 +281,16 @@ void onQuickStartFarmElement(net::Session& session, Player* player = playerOf(session, "QuickStartFarmElement"); if (player == nullptr) return; + const data::Tables& tables = data::Tables::get(); uint32_t worldLevel = req.world_level != 0 ? req.world_level : player->worldLevel(); BattleRequest request; - uint32_t stageId = data::Tables::get().farmElementStage(req.PAOFHFLFFHD, worldLevel); + request.worldLevel = worldLevel; + uint32_t stageId = tables.farmElementStage(req.PAOFHFLFFHD, worldLevel); if (stageId != 0) request.stageIds.push_back(stageId); + if (const data::FarmElementInfo* element = tables.farmElement(stageId)) { + request.staminaCost = element->staminaCost; + request.mappingInfoId = element->mappingInfoId; + } proto::QuickStartFarmElementScRsp rsp; rsp.PAOFHFLFFHD = req.PAOFHFLFFHD; @@ -250,6 +298,8 @@ void onQuickStartFarmElement(net::Session& session, if (request.stageIds.empty()) { logging::warn("battle", "farm element {} has no stage", req.PAOFHFLFFHD); rsp.retcode = fail(proto::Retcode::RET_STAGE_NOT_FOUND); + } else if (!affordable(*player, request)) { + rsp.retcode = fail(proto::Retcode::RET_LACK_STAMINA); } else { rsp.retcode = 0; auto& info = rsp.battle_info.emplace(); @@ -323,11 +373,18 @@ void onReEnterLastElementStage(net::Session& session, // The retry button names the stage it wants replayed. BattleRequest request; if (req.stage_id != 0) request.stageIds.push_back(req.stage_id); + if (const data::FarmElementInfo* element = data::Tables::get().farmElement(req.stage_id)) { + request.staminaCost = element->staminaCost; + request.mappingInfoId = element->mappingInfoId; + request.worldLevel = element->worldLevel; + } proto::ReEnterLastElementStageScRsp rsp; rsp.stage_id = req.stage_id; if (request.stageIds.empty()) { rsp.retcode = fail(proto::Retcode::RET_STAGE_NOT_FOUND); + } else if (!affordable(*player, request)) { + rsp.retcode = fail(proto::Retcode::RET_LACK_STAMINA); } else { rsp.retcode = 0; rsp.battle_info = battle::create(*player, request); @@ -335,14 +392,73 @@ void onReEnterLastElementStage(net::Session& session, session.send(cmd::ReEnterLastElementStageScRsp, rsp); } +// Clears the calyx as many times as the stamina covers, in one go. +void onCocoonSweep(net::Session& session, const proto::CocoonSweepCsReq& req) { + Player* player = playerOf(session, "CocoonSweep"); + if (player == nullptr) return; + + uint32_t worldLevel = req.world_level != 0 ? req.world_level : player->worldLevel(); + const data::CocoonInfo* cocoon = data::Tables::get().cocoon(req.cocoon_id, worldLevel); + inventory::refreshStamina(*player, now()); + + proto::CocoonSweepScRsp rsp; + rsp.cocoon_id = req.cocoon_id; + if (cocoon == nullptr || cocoon->staminaCost == 0) { + rsp.retcode = fail(proto::Retcode::RET_STAGE_NOT_FOUND); + } else if (player->stamina() < cocoon->staminaCost) { + rsp.retcode = fail(proto::Retcode::RET_LACK_STAMINA); + } else { + uint32_t runs = player->stamina() / cocoon->staminaCost; + std::vector drops = payOut(session, *player, cocoon->mappingInfoId, worldLevel, + runs, runs * cocoon->staminaCost); + rsp.retcode = 0; + rsp.NCEFJFOHGBI = runs; + rsp.multiple_drop_data = inventory::itemList(drops); + } + session.send(cmd::CocoonSweepScRsp, rsp); +} + +// The same for a Stagnant Shadow, named the way QuickStartFarmElement names it. +void onFarmElementSweep(net::Session& session, const proto::FarmElementSweepCsReq& req) { + Player* player = playerOf(session, "FarmElementSweep"); + if (player == nullptr) return; + + const data::Tables& tables = data::Tables::get(); + uint32_t worldLevel = req.world_level != 0 ? req.world_level : player->worldLevel(); + const data::FarmElementInfo* element = + tables.farmElement(tables.farmElementStage(req.PAOFHFLFFHD, worldLevel)); + inventory::refreshStamina(*player, now()); + + proto::FarmElementSweepScRsp rsp; + rsp.PAOFHFLFFHD = req.PAOFHFLFFHD; + if (element == nullptr || element->staminaCost == 0) { + rsp.retcode = fail(proto::Retcode::RET_STAGE_NOT_FOUND); + } else if (player->stamina() < element->staminaCost) { + rsp.retcode = fail(proto::Retcode::RET_LACK_STAMINA); + } else { + uint32_t runs = player->stamina() / element->staminaCost; + uint32_t level = element->worldLevel != 0 ? element->worldLevel : worldLevel; + std::vector drops = payOut(session, *player, element->mappingInfoId, level, + runs, runs * element->staminaCost); + rsp.retcode = 0; + rsp.multiple_drop_data = inventory::itemList(drops); + } + session.send(cmd::FarmElementSweepScRsp, rsp); +} + void onPveBattleResult(net::Session& session, const proto::PVEBattleResultCsReq& req) { Player* player = playerOf(session, "PVEBattleResult"); if (player == nullptr) return; BattleContext& context = player->battle(); - if (req.end_status == proto::BattleEndStatus::BATTLE_END_WIN && context.active && - context.battleId == req.battle_id) { - notify::monstersRemoved(session, *player, context.monsterEntityIds); + bool won = req.end_status == proto::BattleEndStatus::BATTLE_END_WIN && context.active && + context.battleId == req.battle_id; + if (won) notify::monstersRemoved(session, *player, context.monsterEntityIds); + // A farming run costs and pays only when it is won; losing or fleeing is free. + std::vector drops; + if (won && (context.staminaCost != 0 || context.mappingInfoId != 0)) { + drops = payOut(session, *player, context.mappingInfoId, context.worldLevel, context.runs, + context.staminaCost); } context.active = false; context.monsterEntityIds.clear(); @@ -360,6 +476,7 @@ void onPveBattleResult(net::Session& session, const proto::PVEBattleResultCsReq& rsp.stage_id = req.stage_id; rsp.end_status = req.end_status; rsp.check_identical = true; + if (!drops.empty()) rsp.drop_data = inventory::itemList(drops); session.send(cmd::PVEBattleResultScRsp, rsp); } @@ -382,6 +499,8 @@ void registerBattleHandlers() { onSceneReviveAfterRebattle); net::on(cmd::ReEnterLastElementStageCsReq, onReEnterLastElementStage); + net::on(cmd::CocoonSweepCsReq, onCocoonSweep); + net::on(cmd::FarmElementSweepCsReq, onFarmElementSweep); } } // namespace game diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp new file mode 100644 index 0000000..7ca47c5 --- /dev/null +++ b/src/game/inventory.cpp @@ -0,0 +1,228 @@ +#include "game/inventory.h" + +#include +#include +#include +#include + +#include "game/player.h" + +namespace game::inventory { +namespace { + +constexpr uint32_t kNoLimit = std::numeric_limits::max(); +constexpr uint32_t kMaxWorldLevel = 6; + +bool inWallet(uint32_t id) { + return id == kStellarJade || id == kCredit || id == kOneiricShard || id == kStamina || + id == kReserveStamina || id == kTrailblazeExp; +} + +void add(uint32_t& field, uint32_t amount, uint32_t limit) { + field = static_cast(std::min(uint64_t{field} + amount, limit)); +} + +uint32_t randomBetween(uint32_t low, uint32_t high, const std::function& roll) { + if (high <= low) return low; + auto offset = static_cast(roll() * static_cast(high - low + 1)); + return low + std::min(offset, high - low); +} + +// How many of a material with no listed count drop, by ItemConfig.PurposeType. +uint32_t dropAmount(const data::ItemInfo& item, uint32_t level) { + switch (item.purposeType) { + case 1: // character EXP + if (item.rarity == 2) return level < 3 ? level + 3 : 2; + if (item.rarity == 3) return level < 3 ? level + 3 : level * 2 - 3; + return 1; + case 2: // Stagnant Shadow materials + return level; + case 3: // trace materials + return 5; + case 4: // Echo of War materials + return (level + 1) / 2; + case 5: // light cone EXP + if (item.rarity == 2) return std::max(level < 5 ? 5 - level : 0, 2); + if (item.rarity == 3) return level % 3 + 1; + return 1; + case 11: + return 4 + level; + default: + return 0; + } +} + +} // namespace + +void refreshStamina(Player& player, int64_t now) { + Inventory& bag = player.inventory(); + const data::StaminaRules& rules = data::Tables::get().staminaRules(); + if (bag.staminaUpdatedAt == 0 || now < bag.staminaUpdatedAt) { + bag.staminaUpdatedAt = now; + return; + } + + if (bag.stamina < rules.max && rules.recoverSeconds != 0) { + int64_t gained = (now - bag.staminaUpdatedAt) / rules.recoverSeconds; + int64_t missing = rules.max - bag.stamina; + if (gained < missing) { + bag.stamina += static_cast(gained); + bag.staminaUpdatedAt += gained * rules.recoverSeconds; + return; + } + bag.stamina = rules.max; + bag.staminaUpdatedAt += missing * rules.recoverSeconds; + } + + // Full, so the time runs into reserve stamina instead. + if (rules.reserveRecoverSeconds == 0 || bag.reserveStamina >= rules.reserveMax) { + bag.staminaUpdatedAt = now; + return; + } + int64_t reserve = (now - bag.staminaUpdatedAt) / rules.reserveRecoverSeconds; + bag.reserveStamina = static_cast( + std::min(rules.reserveMax, int64_t{bag.reserveStamina} + reserve)); + bag.staminaUpdatedAt += reserve * rules.reserveRecoverSeconds; + if (bag.reserveStamina >= rules.reserveMax) bag.staminaUpdatedAt = now; +} + +int64_t nextRecoverTime(const Player& player) { + const Inventory& bag = player.inventory(); + const data::StaminaRules& rules = data::Tables::get().staminaRules(); + return bag.stamina < rules.max ? bag.staminaUpdatedAt + rules.recoverSeconds : 0; +} + +void chargeStamina(Player& player, uint32_t cost, int64_t now) { + refreshStamina(player, now); + Inventory& bag = player.inventory(); + bool wasFull = bag.stamina >= data::Tables::get().staminaRules().max; + bag.stamina -= std::min(cost, bag.stamina); + if (wasFull && bag.stamina < data::Tables::get().staminaRules().max) bag.staminaUpdatedAt = now; +} + +void grant(Player& player, const std::vector& items) { + Inventory& bag = player.inventory(); + const data::Tables& tables = data::Tables::get(); + for (const data::ItemStack& stack : items) { + if (stack.num == 0) continue; + switch (stack.id) { + case kStellarJade: add(bag.hcoin, stack.num, kNoLimit); break; + case kCredit: add(bag.scoin, stack.num, kNoLimit); break; + case kOneiricShard: add(bag.mcoin, stack.num, kNoLimit); break; + case kStamina: add(bag.stamina, stack.num, kNoLimit); break; + case kReserveStamina: + add(bag.reserveStamina, stack.num, tables.staminaRules().reserveMax); + break; + case kTrailblazeExp: break; + default: { + const data::ItemInfo* info = tables.item(stack.id); + uint32_t limit = info != nullptr && info->pileLimit != 0 ? info->pileLimit : kNoLimit; + add(bag.items[stack.id], stack.num, limit); + } + } + } +} + +const data::MappingInfo* dropTable(uint32_t mappingInfoId, uint32_t worldLevel) { + if (mappingInfoId == 0) return nullptr; + const data::Tables& tables = data::Tables::get(); + int64_t wanted = std::min(worldLevel, kMaxWorldLevel); + // Downwards first: a lower level never pays more than the player earned. + for (int64_t distance = 0; distance <= kMaxWorldLevel; ++distance) { + for (int64_t level : {wanted - distance, wanted + distance}) { + if (level < 0 || level > kMaxWorldLevel || (distance == 0 && level != wanted)) continue; + const data::MappingInfo* table = tables.mappingInfo(mappingInfoId, static_cast(level)); + if (table != nullptr && !table->display.empty()) return table; + } + } + return nullptr; +} + +std::vector rollDrops(const data::MappingInfo& table, + const std::function& roll) { + const data::Tables& tables = data::Tables::get(); + uint32_t level = table.worldLevel; + std::vector drops; + for (const data::ItemStack& shown : table.display) { + uint32_t amount = shown.num; + if (amount == 0 && shown.id == kCredit) { + amount = randomBetween((50 + level * 10) * table.farmType, + (100 + level * 10) * table.farmType, roll); + } else if (amount == 0) { + const data::ItemInfo* item = tables.item(shown.id); + if (item != nullptr && item->mainType == "Material") amount = dropAmount(*item, level); + } + if (amount != 0) drops.push_back({shown.id, amount}); + } + return drops; +} + +std::vector rewardItems(const data::RewardInfo& reward) { + std::vector items; + if (reward.hcoin != 0) items.push_back({kStellarJade, reward.hcoin}); + items.insert(items.end(), reward.items.begin(), reward.items.end()); + return items; +} + +std::vector merged(const std::vector& items) { + std::vector out; + for (const data::ItemStack& stack : items) { + auto it = std::find_if(out.begin(), out.end(), + [&](const data::ItemStack& s) { return s.id == stack.id; }); + if (it == out.end()) { + out.push_back(stack); + } else { + add(it->num, stack.num, kNoLimit); + } + } + return out; +} + +proto::ItemList itemList(const std::vector& items) { + proto::ItemList list; + for (const data::ItemStack& stack : items) { + proto::Item item; + item.item_id = stack.id; + item.num = stack.num; + list.item_list.push_back(item); + } + return list; +} + +proto::PlayerBasicInfo basicInfo(const Player& player) { + proto::PlayerBasicInfo info; + info.nickname = player.name(); + info.level = player.level(); + info.world_level = player.worldLevel(); + info.stamina = player.stamina(); + info.hcoin = player.hcoin(); + info.scoin = player.scoin(); + info.mcoin = player.mcoin(); + return info; +} + +proto::PlayerSyncScNotify sync(const Player& player, const std::vector& changed) { + proto::PlayerSyncScNotify notify; + notify.basic_info.emplace() = basicInfo(player); + const Inventory& bag = player.inventory(); + std::set seen; + for (const data::ItemStack& stack : changed) { + if (inWallet(stack.id) || !seen.insert(stack.id).second) continue; + auto it = bag.items.find(stack.id); + proto::Material material; + material.tid = stack.id; + material.num = it == bag.items.end() ? 0 : it->second; + notify.material_list.push_back(material); + } + return notify; +} + +proto::StaminaInfoScNotify staminaInfo(const Player& player) { + proto::StaminaInfoScNotify notify; + notify.stamina = player.stamina(); + notify.next_recover_time = nextRecoverTime(player); + notify.reserve_stamina = player.inventory().reserveStamina; + return notify; +} + +} // namespace game::inventory diff --git a/src/game/inventory.h b/src/game/inventory.h new file mode 100644 index 0000000..915d0fd --- /dev/null +++ b/src/game/inventory.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +#include "data/excel.h" +#include "proto/gen/protos.h" + +namespace game { + +class Player; + +namespace inventory { + +// Ids the wallet and stamina use instead of the bag. +constexpr uint32_t kStellarJade = 1; +constexpr uint32_t kCredit = 2; +constexpr uint32_t kOneiricShard = 3; +constexpr uint32_t kStamina = 11; +constexpr uint32_t kReserveStamina = 12; +constexpr uint32_t kTrailblazeExp = 22; + +// Stamina and reserve stamina brought up to `now` (unix seconds) at the table's rates. +void refreshStamina(Player& player, int64_t now); +// When the next point comes in; 0 while stamina is full. +int64_t nextRecoverTime(const Player& player); +// Takes up to `cost`. Dropping below full starts the recovery clock. +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); + +// A farming spot's drop table at `worldLevel`, or at the nearest world level that lists +// any drops: the tables leave some levels empty. +const data::MappingInfo* dropTable(uint32_t mappingInfoId, uint32_t worldLevel); +// One run's drops. A listed count drops as is; the rest are sized by the item's kind and +// the table's world level. +std::vector rollDrops(const data::MappingInfo& table, + const std::function& roll); +// RewardData as items, its Stellar Jade included. +std::vector rewardItems(const data::RewardInfo& reward); +// Repeats of one item summed, in first-seen order. +std::vector merged(const std::vector& items); + +proto::ItemList itemList(const std::vector& items); +proto::PlayerBasicInfo basicInfo(const Player& player); +// The bag entries for `changed`, with the wallet and stamina alongside. +proto::PlayerSyncScNotify sync(const Player& player, const std::vector& changed); +proto::StaminaInfoScNotify staminaInfo(const Player& player); + +} // namespace inventory +} // namespace game diff --git a/src/game/inventory_handlers.cpp b/src/game/inventory_handlers.cpp index c446386..354ee15 100644 --- a/src/game/inventory_handlers.cpp +++ b/src/game/inventory_handlers.cpp @@ -1,5 +1,11 @@ +#include +#include + #include "core/logger.h" +#include "core/util.h" +#include "data/excel.h" #include "game/handlers.h" +#include "game/inventory.h" #include "game/player.h" #include "net/cmd_ids.h" #include "net/handler.h" @@ -9,16 +15,40 @@ namespace game { namespace { -// Warp currency, so the banner screens are usable without a real inventory. +// Warp currency, kept topped up: pulls only show results and never spend it. constexpr uint32_t kStockedMaterials[] = {101, 102}; constexpr uint32_t kStockedAmount = 999999; -void onGetBag(net::Session& session, const proto::GetBagCsReq&) { +Player* playerOf(net::Session& session, const char* what) { Player* player = session.player(); - if (player == nullptr) { - logging::warn("game", "GetBag before login"); - return; + if (player == nullptr) logging::warn("game", "{} before login", what); + return player; +} + +bool stocked(uint32_t tid) { + for (uint32_t id : kStockedMaterials) { + if (id == tid) return true; } + return false; +} + +int64_t now() { return static_cast(util::nowSec()); } + +// The local date as yyyymmdd, which the daily stamina purchases count against. +int64_t localDate(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 int64_t{tm.tm_year + 1900} * 10000 + (tm.tm_mon + 1) * 100 + tm.tm_mday; +} + +void onGetBag(net::Session& session, const proto::GetBagCsReq&) { + Player* player = playerOf(session, "GetBag"); + if (player == nullptr) return; Roster roster = player->roster(); proto::GetBagScRsp rsp; @@ -29,6 +59,13 @@ void onGetBag(net::Session& session, const proto::GetBagCsReq&) { for (const Relic& relic : roster.data().relics) { rsp.relic_list.push_back(roster.toRelic(relic)); } + for (const auto& [tid, count] : player->inventory().items) { + if (count == 0 || stocked(tid)) continue; + proto::Material material; + material.tid = tid; + material.num = count; + rsp.material_list.push_back(material); + } for (uint32_t tid : kStockedMaterials) { proto::Material material; material.tid = tid; @@ -38,10 +75,78 @@ void onGetBag(net::Session& session, const proto::GetBagCsReq&) { session.send(cmd::GetBagScRsp, rsp); } +// Stellar Jade for stamina, at StaminaSaleConfig's price for today's purchase number. +void onExchangeStamina(net::Session& session, const proto::ExchangeStaminaCsReq&) { + Player* player = playerOf(session, "ExchangeStamina"); + if (player == nullptr) return; + + const data::Tables& tables = data::Tables::get(); + inventory::refreshStamina(*player, now()); + Inventory& bag = player->inventory(); + if (int64_t today = localDate(now()); bag.purchaseDay != today) { + bag.purchaseDay = today; + bag.purchasesToday = 0; + } + + const std::vector& prices = tables.staminaPrices(); + proto::ExchangeStaminaScRsp rsp; + if (bag.purchasesToday >= prices.size()) { + rsp.retcode = fail(proto::Retcode::RET_LACK_EXCHANGE_STAMINA_TIMES); + } else if (bag.hcoin < prices[bag.purchasesToday]) { + rsp.retcode = fail(proto::Retcode::RET_ITEM_NOT_ENOUGH); + } else { + uint32_t price = prices[bag.purchasesToday]; + bag.hcoin -= price; + ++bag.purchasesToday; + inventory::grant(*player, {{inventory::kStamina, tables.staminaPerPurchase()}}); + + rsp.retcode = 0; + rsp.stamina_add = tables.staminaPerPurchase(); + rsp.exchange_times = bag.purchasesToday; + rsp.last_recover_time = inventory::nextRecoverTime(*player); + proto::ItemCost cost; + auto& jade = cost.pile_item.emplace(); + jade.item_id = inventory::kStellarJade; + jade.item_num = price; + cost.item_case = proto::ItemCost::k_pile_item; + rsp.item_cost_list.push_back(cost); + + session.send(cmd::PlayerSyncScNotify, inventory::sync(*player, {})); + session.send(cmd::StaminaInfoScNotify, inventory::staminaInfo(*player)); + player->saveNow(); + } + session.send(cmd::ExchangeStaminaScRsp, rsp); +} + +// Moves banked reserve stamina into stamina. +void onReserveStaminaExchange(net::Session& session, const proto::ReserveStaminaExchangeCsReq& req) { + Player* player = playerOf(session, "ReserveStaminaExchange"); + if (player == nullptr) return; + + inventory::refreshStamina(*player, now()); + Inventory& bag = player->inventory(); + proto::ReserveStaminaExchangeScRsp rsp; + if (req.num == 0 || req.num > bag.reserveStamina) { + rsp.retcode = fail(proto::Retcode::RET_ITEM_NOT_ENOUGH); + } else { + bag.reserveStamina -= req.num; + inventory::grant(*player, {{inventory::kStamina, req.num}}); + rsp.retcode = 0; + rsp.num = req.num; + session.send(cmd::PlayerSyncScNotify, inventory::sync(*player, {})); + session.send(cmd::StaminaInfoScNotify, inventory::staminaInfo(*player)); + player->saveNow(); + } + session.send(cmd::ReserveStaminaExchangeScRsp, rsp); +} + } // namespace void registerInventoryHandlers() { net::on(cmd::GetBagCsReq, onGetBag); + net::on(cmd::ExchangeStaminaCsReq, onExchangeStamina); + net::on(cmd::ReserveStaminaExchangeCsReq, + onReserveStaminaExchange); } } // namespace game diff --git a/src/game/login_handlers.cpp b/src/game/login_handlers.cpp index 455839a..4386a02 100644 --- a/src/game/login_handlers.cpp +++ b/src/game/login_handlers.cpp @@ -2,6 +2,7 @@ #include "core/logger.h" #include "core/util.h" #include "game/handlers.h" +#include "game/inventory.h" #include "game/player.h" #include "net/cmd_ids.h" #include "net/handler.h" @@ -21,13 +22,7 @@ constexpr uint32_t kUnlockedContentPackages[] = { 140006, 171002}; void fillBasicInfo(proto::PlayerBasicInfo& info, const Player& player) { - info.nickname = player.name(); - info.level = player.level(); - info.world_level = player.worldLevel(); - info.stamina = player.stamina(); - info.hcoin = player.hcoin(); - info.scoin = player.scoin(); - info.mcoin = player.mcoin(); + info = inventory::basicInfo(player); } void onGetToken(net::Session& session, const proto::PlayerGetTokenCsReq& req) { @@ -40,6 +35,7 @@ void onGetToken(net::Session& session, const proto::PlayerGetTokenCsReq& req) { player->setMarchType(core::Config::get().gameplay.marchType); player->setGlobalBuffs(core::Config::get().gameplay.globalBuffs); player->load(); + inventory::refreshStamina(*player, static_cast(util::nowSec())); session.setPlayer(player); session.setState(net::SessionState::WaitingForLogin); @@ -85,6 +81,7 @@ void onLoginFinish(net::Session& session, const proto::PlayerLoginFinishCsReq&) data.content_package_list.push_back(info); } session.send(cmd::ContentPackageSyncDataScNotify, notify); + session.send(cmd::StaminaInfoScNotify, inventory::staminaInfo(*player)); } proto::PlayerLoginFinishScRsp rsp; @@ -110,7 +107,7 @@ void onGetBasicInfo(net::Session& session, const proto::GetBasicInfoCsReq&) { rsp.is_gender_set = true; rsp.gender = player != nullptr ? player->gender() : 2; rsp.cur_day = 1; - rsp.next_recover_time = static_cast(util::nowSec() + 300); + rsp.next_recover_time = player != nullptr ? inventory::nextRecoverTime(*player) : 0; session.send(cmd::GetBasicInfoScRsp, rsp); } diff --git a/src/game/player.cpp b/src/game/player.cpp index 122e044..a3bd1cd 100644 --- a/src/game/player.cpp +++ b/src/game/player.cpp @@ -90,11 +90,11 @@ Player::Player(uint32_t uid) : uid_(uid) { signature_ = defaults.signature; level_ = defaults.level; worldLevel_ = defaults.worldLevel; - stamina_ = defaults.stamina; headIcon_ = defaults.headIcon; - hcoin_ = defaults.hcoin; - scoin_ = defaults.scoin; - mcoin_ = defaults.mcoin; + inventory_.stamina = defaults.stamina; + inventory_.hcoin = defaults.hcoin; + inventory_.scoin = defaults.scoin; + inventory_.mcoin = defaults.mcoin; } uint32_t Player::gender() const { diff --git a/src/game/player.h b/src/game/player.h index 1635a15..88b9e4f 100644 --- a/src/game/player.h +++ b/src/game/player.h @@ -23,6 +23,24 @@ struct BattleContext { uint32_t wave = 0; std::vector monsterEntityIds; bool active = false; + // A calyx or shadow run: what a win costs and which drop table it pays from. + uint32_t staminaCost = 0; + uint32_t mappingInfoId = 0; + uint32_t worldLevel = 0; + uint32_t runs = 1; +}; + +// Stamina, the wallet and the bag. Saved. +struct Inventory { + uint32_t stamina = 240; + uint32_t reserveStamina = 0; + int64_t staminaUpdatedAt = 0; // unix seconds stamina was last brought up to date + uint32_t purchasesToday = 0; + int64_t purchaseDay = 0; // the local date of those purchases, as yyyymmdd + uint32_t hcoin = 0; + uint32_t scoin = 0; + uint32_t mcoin = 0; + std::map items; }; // A challenge run in progress. Not persisted: leaving the client mid-floor drops the @@ -137,12 +155,14 @@ public: void setSignature(std::string value) { signature_ = std::move(value); } uint32_t level() const { return level_; } uint32_t worldLevel() const { return worldLevel_; } - uint32_t stamina() const { return stamina_; } + uint32_t stamina() const { return inventory_.stamina; } uint32_t headIcon() const { return headIcon_; } void setHeadIcon(uint32_t value) { headIcon_ = value; } - uint32_t hcoin() const { return hcoin_; } - uint32_t scoin() const { return scoin_; } - uint32_t mcoin() const { return mcoin_; } + uint32_t hcoin() const { return inventory_.hcoin; } + uint32_t scoin() const { return inventory_.scoin; } + uint32_t mcoin() const { return inventory_.mcoin; } + Inventory& inventory() { return inventory_; } + const Inventory& inventory() const { return inventory_; } uint64_t loginRandom() const { return loginRandom_; } void setLoginRandom(uint64_t value) { loginRandom_ = value; } @@ -195,12 +215,9 @@ private: std::string signature_; uint32_t level_ = 1; uint32_t worldLevel_ = 0; - uint32_t stamina_ = 240; uint32_t headIcon_ = 201001; - uint32_t hcoin_ = 0; - uint32_t scoin_ = 0; - uint32_t mcoin_ = 0; uint64_t loginRandom_ = 0; + Inventory inventory_; LineupBook lineups_; Position position_; diff --git a/src/game/player_store.cpp b/src/game/player_store.cpp index 390f1b7..0576196 100644 --- a/src/game/player_store.cpp +++ b/src/game/player_store.cpp @@ -1,5 +1,6 @@ #include "game/player_store.h" +#include #include #include "core/config.h" @@ -23,6 +24,11 @@ int32_t i32(const json& j, const char* key, int32_t fallback) { return (it != j.end() && it->is_number()) ? it->get() : fallback; } +int64_t i64(const json& j, const char* key, int64_t fallback) { + auto it = j.find(key); + return (it != j.end() && it->is_number()) ? it->get() : fallback; +} + bool boolOr(const json& j, const char* key, bool fallback) { auto it = j.find(key); return (it != j.end() && it->is_boolean()) ? it->get() : fallback; @@ -188,6 +194,26 @@ bool loadPlayerState(Player& player) { readPity("character", player.gacha().character); readPity("lightcone", player.gacha().lightcone); } + + if (auto saved = j.find("inventory"); saved != j.end() && saved->is_object()) { + Inventory& bag = player.inventory(); + bag.stamina = u32(*saved, "stamina", bag.stamina); + bag.reserveStamina = u32(*saved, "reserve_stamina", 0); + bag.staminaUpdatedAt = i64(*saved, "stamina_updated_at", 0); + bag.purchasesToday = u32(*saved, "stamina_purchases", 0); + bag.purchaseDay = i64(*saved, "stamina_purchase_day", 0); + bag.hcoin = u32(*saved, "hcoin", bag.hcoin); + bag.scoin = u32(*saved, "scoin", bag.scoin); + bag.mcoin = u32(*saved, "mcoin", bag.mcoin); + if (auto items = saved->find("items"); items != saved->end() && items->is_object()) { + for (const auto& entry : items->items()) { + auto id = static_cast(std::strtoul(entry.key().c_str(), nullptr, 10)); + if (id != 0 && entry.value().is_number_unsigned()) { + bag.items[id] = entry.value().get(); + } + } + } + } return true; } @@ -272,6 +298,21 @@ std::string playerStateJson(const Player& player) { {"character", pity(gacha.character)}, {"lightcone", pity(gacha.lightcone)}}; + const Inventory& bag = player.inventory(); + json items = json::object(); + for (const auto& [id, count] : bag.items) { + if (count != 0) items[std::to_string(id)] = count; + } + j["inventory"] = {{"stamina", bag.stamina}, + {"reserve_stamina", bag.reserveStamina}, + {"stamina_updated_at", bag.staminaUpdatedAt}, + {"stamina_purchases", bag.purchasesToday}, + {"stamina_purchase_day", bag.purchaseDay}, + {"hcoin", bag.hcoin}, + {"scoin", bag.scoin}, + {"mcoin", bag.mcoin}, + {"items", items}}; + return j.dump(2); } diff --git a/tests/test_flow.cpp b/tests/test_flow.cpp index ae34401..80e79c0 100644 --- a/tests/test_flow.cpp +++ b/tests/test_flow.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -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(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); diff --git a/tests/test_game.cpp b/tests/test_game.cpp index a7aa7ae..60d5f88 100644 --- a/tests/test_game.cpp +++ b/tests/test_game.cpp @@ -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 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 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();