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

+12 -1
View File
@@ -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
+7
View File
@@ -20,6 +20,10 @@ struct BattleRequest {
std::vector<uint32_t> 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
+133 -14
View File
@@ -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<int64_t>(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<uint32_t>(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<data::ItemStack> payOut(net::Session& session, Player& player, uint32_t mappingInfoId,
uint32_t worldLevel, uint32_t runs, uint32_t cost) {
std::vector<data::ItemStack> drops;
if (const data::MappingInfo* table = inventory::dropTable(mappingInfoId, worldLevel)) {
for (uint32_t run = 0; run < runs; ++run) {
std::vector<data::ItemStack> 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<data::ItemStack> 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<data::ItemStack> 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<data::ItemStack> 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<proto::ReEnterLastElementStageCsReq>(cmd::ReEnterLastElementStageCsReq,
onReEnterLastElementStage);
net::on<proto::CocoonSweepCsReq>(cmd::CocoonSweepCsReq, onCocoonSweep);
net::on<proto::FarmElementSweepCsReq>(cmd::FarmElementSweepCsReq, onFarmElementSweep);
}
} // namespace game
+228
View File
@@ -0,0 +1,228 @@
#include "game/inventory.h"
#include <algorithm>
#include <limits>
#include <map>
#include <set>
#include "game/player.h"
namespace game::inventory {
namespace {
constexpr uint32_t kNoLimit = std::numeric_limits<uint32_t>::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<uint32_t>(std::min<uint64_t>(uint64_t{field} + amount, limit));
}
uint32_t randomBetween(uint32_t low, uint32_t high, const std::function<double()>& roll) {
if (high <= low) return low;
auto offset = static_cast<uint32_t>(roll() * static_cast<double>(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<uint32_t>(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<uint32_t>(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<uint32_t>(
std::min<int64_t>(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<data::ItemStack>& 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<uint32_t>(level));
if (table != nullptr && !table->display.empty()) return table;
}
}
return nullptr;
}
std::vector<data::ItemStack> rollDrops(const data::MappingInfo& table,
const std::function<double()>& roll) {
const data::Tables& tables = data::Tables::get();
uint32_t level = table.worldLevel;
std::vector<data::ItemStack> 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<data::ItemStack> rewardItems(const data::RewardInfo& reward) {
std::vector<data::ItemStack> 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<data::ItemStack> merged(const std::vector<data::ItemStack>& items) {
std::vector<data::ItemStack> 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<data::ItemStack>& 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<data::ItemStack>& changed) {
proto::PlayerSyncScNotify notify;
notify.basic_info.emplace() = basicInfo(player);
const Inventory& bag = player.inventory();
std::set<uint32_t> 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
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <cstdint>
#include <functional>
#include <vector>
#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<data::ItemStack>& 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<data::ItemStack> rollDrops(const data::MappingInfo& table,
const std::function<double()>& roll);
// RewardData as items, its Stellar Jade included.
std::vector<data::ItemStack> rewardItems(const data::RewardInfo& reward);
// Repeats of one item summed, in first-seen order.
std::vector<data::ItemStack> merged(const std::vector<data::ItemStack>& items);
proto::ItemList itemList(const std::vector<data::ItemStack>& 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<data::ItemStack>& changed);
proto::StaminaInfoScNotify staminaInfo(const Player& player);
} // namespace inventory
} // namespace game
+110 -5
View File
@@ -1,5 +1,11 @@
#include <ctime>
#include <vector>
#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<int64_t>(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<std::time_t>(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<uint32_t>& 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<proto::GetBagCsReq>(cmd::GetBagCsReq, onGetBag);
net::on<proto::ExchangeStaminaCsReq>(cmd::ExchangeStaminaCsReq, onExchangeStamina);
net::on<proto::ReserveStaminaExchangeCsReq>(cmd::ReserveStaminaExchangeCsReq,
onReserveStaminaExchange);
}
} // namespace game
+5 -8
View File
@@ -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<int64_t>(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<int64_t>(util::nowSec() + 300);
rsp.next_recover_time = player != nullptr ? inventory::nextRecoverTime(*player) : 0;
session.send(cmd::GetBasicInfoScRsp, rsp);
}
+4 -4
View File
@@ -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 {
+25 -8
View File
@@ -23,6 +23,24 @@ struct BattleContext {
uint32_t wave = 0;
std::vector<uint32_t> 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<uint32_t, uint32_t> 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_;
+41
View File
@@ -1,5 +1,6 @@
#include "game/player_store.h"
#include <cstdlib>
#include <nlohmann/json.hpp>
#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<int32_t>() : 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<int64_t>() : 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<bool>() : 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<uint32_t>(std::strtoul(entry.key().c_str(), nullptr, 10));
if (id != 0 && entry.value().is_number_unsigned()) {
bag.items[id] = entry.value().get<uint32_t>();
}
}
}
}
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);
}
+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();