feat(game): claim challenge rewards and keep records

This commit is contained in:
capyb2222 committed 2026-09-12 22:04:52 -07:00
1 parent ab75080134
commit 38880e4976
7 files changed
+337 -41

No files matched your search

+196 -6
View File
@@ -1,11 +1,13 @@
#include "game/challenge.h"
#include <algorithm>
#include <bit>
#include <utility>
#include "core/config.h"
#include "core/logger.h"
#include "data/excel.h"
#include "game/inventory.h"
#include "game/player.h"
#include "net/cmd_ids.h"
#include "net/session.h"
@@ -21,6 +23,19 @@ constexpr std::pair<uint32_t, uint32_t> kMaxLevels[] = {{1, 12}, {2, 4}, {3, 4}}
uint32_t half(uint32_t stage) { return stage > 1 ? 1u : 0u; }
uint32_t fullStars(const data::ChallengeInfo& floor) {
return floor.targetIds.empty() ? 0 : (uint32_t{1} << floor.targetIds.size()) - 1;
}
// The floor's stars as the history reports them: all of them under
// unlock_all_challenges, otherwise the best the player has cleared it with.
uint32_t floorStars(const Player* player, const data::ChallengeInfo& floor) {
if (core::Config::get().gameplay.unlockAllChallenges) return fullStars(floor);
if (player == nullptr) return 0;
auto it = player->challengeRecords().find(floor.id);
return it == player->challengeRecords().end() ? 0 : it->second.stars;
}
const data::ChallengeInfo* configOf(const Player& player) {
const ChallengeRun& run = player.challenge();
if (!run.active) return nullptr;
@@ -42,11 +57,39 @@ proto::ExtraLineupType lineupTypeOf(uint32_t stage) {
: proto::ExtraLineupType::ExtraLineupType_LineupChallenge;
}
// Keeps the run as the floor's record when it beats the one there: more stars first, then
// fewer cycles for Memory of Chaos or a higher score for the other two.
void recordClear(Player& player) {
const data::ChallengeInfo* config = configOf(player);
if (config == nullptr) return;
const ChallengeRun& run = player.challenge();
ChallengeRecord candidate;
candidate.stars = run.stars;
candidate.roundsUsed = config->roundLimit > run.roundsLeft ? config->roundLimit - run.roundsLeft : 0;
candidate.score = run.totalScore();
for (uint32_t i = 0; i < 2; ++i) {
candidate.buffs[i] = run.buffs[i];
candidate.teams[i] = run.party[i];
}
auto [it, fresh] = player.challengeRecords().try_emplace(config->id, candidate);
if (fresh) return;
const ChallengeRecord& best = it->second;
int have = std::popcount(best.stars);
int got = std::popcount(candidate.stars);
bool better = got > have ||
(got == have && (config->kind == data::ChallengeKind::Memory
? candidate.roundsUsed < best.roundsUsed
: candidate.score > best.score));
if (better) it->second = candidate;
}
} // namespace
namespace challenge {
proto::GetChallengeScRsp history() {
proto::GetChallengeScRsp history(const Player* player) {
const data::Tables& tables = data::Tables::get();
bool cleared = core::Config::get().gameplay.unlockAllChallenges;
@@ -56,17 +99,20 @@ proto::GetChallengeScRsp history() {
for (const data::ChallengeInfo& floor : tables.challenges()) {
proto::Challenge entry;
entry.challenge_id = floor.id;
if (cleared && !floor.targetIds.empty()) {
entry.star = (uint32_t{1} << floor.targetIds.size()) - 1;
entry.taken_reward = entry.star;
}
entry.star = floorStars(player, floor);
entry.taken_reward = entry.star;
rsp.challenge_list.push_back(entry);
}
for (const data::ChallengeGroupInfo& season : tables.challengeGroups()) {
proto::ChallengeGroup group;
group.group_id = season.id;
if (cleared) {
if (player != nullptr) {
// Only what has actually been claimed; the rest shows as a reward to collect.
const auto& taken = player->challengeRewardsTaken();
auto it = taken.find(season.id);
group.taken_stars_count_reward = it == taken.end() ? 0 : it->second;
} else if (cleared) {
group.taken_stars_count_reward = tables.challengeRewardStars(season.rewardLineGroupId);
}
rsp.challenge_group_list.push_back(group);
@@ -149,6 +195,146 @@ bool enterArena(Player& player, proto::SceneInfo& out) {
return scene::load(player, entryId, 0, true, out, filter);
}
proto::TakeChallengeRewardScRsp takeRewards(Player& player, uint32_t groupId,
std::vector<data::ItemStack>& granted) {
const data::Tables& tables = data::Tables::get();
proto::TakeChallengeRewardScRsp rsp;
rsp.retcode = 0;
rsp.group_id = groupId;
granted.clear();
const data::ChallengeGroupInfo* season = tables.challengeGroup(groupId);
const std::vector<data::ChallengeRewardLine>* line =
season != nullptr ? tables.challengeRewardLine(season->rewardLineGroupId) : nullptr;
if (line == nullptr) {
rsp.OAKNHADPLJD.emplace();
return rsp;
}
uint32_t earned = 0;
for (const data::ChallengeInfo& floor : tables.challenges()) {
if (floor.groupId == groupId) earned += std::popcount(floorStars(&player, floor));
}
uint64_t& taken = player.challengeRewardsTaken()[groupId];
std::vector<data::ItemStack> all;
for (const data::ChallengeRewardLine& step : *line) {
uint64_t bit = uint64_t{1} << step.stars;
if (step.stars > earned || (taken & bit) != 0) continue;
taken |= bit;
std::vector<data::ItemStack> items;
if (const data::RewardInfo* reward = tables.reward(step.rewardId)) {
items = inventory::rewardItems(*reward);
}
proto::TakenChallengeRewardInfo info;
info.star_count = step.stars;
info.reward.emplace() = inventory::itemList(items);
rsp.taken_reward_list.push_back(std::move(info));
all.insert(all.end(), items.begin(), items.end());
}
granted = inventory::merged(all);
inventory::grant(player, granted);
rsp.OAKNHADPLJD.emplace() = inventory::itemList(granted);
return rsp;
}
proto::GetChallengeGroupStatisticsScRsp statistics(const Player& player, uint32_t groupId) {
const data::Tables& tables = data::Tables::get();
proto::GetChallengeGroupStatisticsScRsp rsp;
rsp.retcode = 0;
rsp.group_id = groupId;
const data::ChallengeGroupInfo* season = tables.challengeGroup(groupId);
data::ChallengeKind kind = season != nullptr ? season->kind : data::ChallengeKind::Memory;
// The highest floor cleared, then the most stars, then the fewest cycles or best score.
const data::ChallengeInfo* bestFloor = nullptr;
const ChallengeRecord* best = nullptr;
for (const data::ChallengeInfo& floor : tables.challenges()) {
if (floor.groupId != groupId) continue;
auto it = player.challengeRecords().find(floor.id);
if (it == player.challengeRecords().end() || it->second.stars == 0) continue;
const ChallengeRecord& record = it->second;
bool better = best == nullptr || floor.floor > bestFloor->floor;
if (!better && floor.floor == bestFloor->floor) {
int have = std::popcount(best->stars);
int got = std::popcount(record.stars);
better = got > have || (got == have && (kind == data::ChallengeKind::Memory
? record.roundsUsed < best->roundsUsed
: record.score > best->score));
}
if (better) {
best = &record;
bestFloor = &floor;
}
}
Roster roster = player.roster();
auto lineups = [&](std::vector<proto::ChallengeLineupList>& out) {
for (const std::vector<uint32_t>& team : best->teams) {
proto::ChallengeLineupList list;
uint32_t index = 0;
for (uint32_t avatarId : team) {
proto::ChallengeAvatarInfo avatar;
avatar.avatar_type = proto::AvatarType::AvatarType_AvatarFormalType;
avatar.id = avatarId;
const Avatar* source = roster.find(roster.resolvePath(avatarId));
avatar.level = source != nullptr ? source->level : 1;
avatar.index = index++;
list.avatar_list.push_back(avatar);
}
out.push_back(std::move(list));
}
};
// The oneof has to be set to the arm that matches the season, or the client reads the
// wrong one and throws; the record inside stays absent until a floor is cleared.
switch (kind) {
case data::ChallengeKind::Story: {
auto& stats = rsp.challenge_story.emplace();
rsp.EDKOHAAMONH_case = proto::GetChallengeGroupStatisticsScRsp::k_challenge_story;
if (best == nullptr) break;
stats.record_id = bestFloor->id;
auto& record = stats.PPBHLLOJNEK.emplace();
record.level = bestFloor->floor;
record.EEJCPNAEKLJ = static_cast<uint32_t>(std::popcount(best->stars));
record.buff_one = best->buffs[0];
record.buff_two = best->buffs[1];
record.score_id = best->score;
lineups(record.lineup_list);
break;
}
case data::ChallengeKind::Boss: {
auto& stats = rsp.challenge_boss.emplace();
rsp.EDKOHAAMONH_case = proto::GetChallengeGroupStatisticsScRsp::k_challenge_boss;
if (best == nullptr) break;
stats.record_id = bestFloor->id;
auto& record = stats.PPBHLLOJNEK.emplace();
record.level = bestFloor->floor;
record.EEJCPNAEKLJ = static_cast<uint32_t>(std::popcount(best->stars));
record.buff_one = best->buffs[0];
record.buff_two = best->buffs[1];
record.score_id = best->score;
lineups(record.lineup_list);
break;
}
case data::ChallengeKind::Memory: {
auto& stats = rsp.challenge_default.emplace();
rsp.EDKOHAAMONH_case = proto::GetChallengeGroupStatisticsScRsp::k_challenge_default;
if (best == nullptr) break;
stats.record_id = bestFloor->id;
auto& record = stats.PPBHLLOJNEK.emplace();
record.level = bestFloor->floor;
record.EEJCPNAEKLJ = static_cast<uint32_t>(std::popcount(best->stars));
record.round_count = best->roundsUsed;
lineups(record.lineup_list);
break;
}
}
return rsp;
}
uint32_t stars(const Player& player) {
const data::ChallengeInfo* config = configOf(player);
if (config == nullptr) return 0;
@@ -195,6 +381,10 @@ void settle(net::Session& session, Player& player, bool win) {
run.status = win ? proto::ChallengeStatus::CHALLENGE_FINISH
: proto::ChallengeStatus::CHALLENGE_FAILED;
run.stars = win ? stars(player) : 0;
if (win) {
recordClear(player);
player.saveNow();
}
proto::ChallengeSettleNotify notify;
notify.challenge_id = run.challengeId;
+12 -1
View File
@@ -1,7 +1,9 @@
#pragma once
#include <cstdint>
#include <vector>
#include "data/excel.h"
#include "game/scene.h"
#include "proto/gen/protos.h"
@@ -20,7 +22,16 @@ namespace challenge {
// The player's challenge history: every floor of every mode, with its stars and its
// season's rewards. Reports a full clear when gameplay.unlock_all_challenges is set.
proto::GetChallengeScRsp history();
// Without a player every reward reads as claimed.
proto::GetChallengeScRsp history(const Player* player = nullptr);
// Pays out every star reward of the season that is earned and not yet claimed, returning
// what was granted in `granted` so the caller can sync it.
proto::TakeChallengeRewardScRsp takeRewards(Player& player, uint32_t groupId,
std::vector<data::ItemStack>& granted);
// The season panel: the best cleared floor of `groupId`, with the teams that cleared it.
proto::GetChallengeGroupStatisticsScRsp statistics(const Player& player, uint32_t groupId);
// The run in progress, as the client models it. Meaningless when none is.
proto::CurChallenge current(const Player& player);
+16 -32
View File
@@ -2,9 +2,8 @@
//
// The history goes out as a full clear: star is a bitmask over the floor's
// ChallengeTargetID entries, and the client locks a floor whose predecessor has none, so
// an empty GetChallengeScRsp leaves every mode showing floor 1 and nothing else. Rewards
// are reported as already taken -- there is no inventory to pay them into, and a
// claimable reward would be a button that does nothing.
// an empty GetChallengeScRsp leaves every mode showing floor 1 and nothing else. A
// season's star rewards are claimable once earned and pay out into the inventory.
//
// Running a floor is the rest of this file. The client sends both teams with the start
// request, so there is no separate lineup to edit: the run holds them, the arena is the
@@ -14,6 +13,7 @@
#include "data/excel.h"
#include "game/challenge.h"
#include "game/handlers.h"
#include "game/inventory.h"
#include "game/player.h"
#include "game/scene.h"
#include "net/cmd_ids.h"
@@ -47,7 +47,7 @@ std::vector<uint32_t> teamOf(const std::vector<proto::AvatarIdentifier>& identif
}
void onGetChallenge(net::Session& session, const proto::GetChallengeCsReq&) {
session.send(cmd::GetChallengeScRsp, challenge::history());
session.send(cmd::GetChallengeScRsp, challenge::history(session.player()));
}
void onStartChallenge(net::Session& session, const proto::StartChallengeCsReq& req) {
@@ -183,37 +183,21 @@ void onLeaveChallenge(net::Session& session, const proto::LeaveChallengeCsReq&)
void onGetChallengeGroupStatistics(net::Session& session,
const proto::GetChallengeGroupStatisticsCsReq& req) {
// Nobody's records are kept, but the oneof has to be set to the shape that matches
// the season or the client reads the wrong arm of it and throws.
proto::GetChallengeGroupStatisticsScRsp rsp;
rsp.retcode = 0;
rsp.group_id = req.group_id;
const data::ChallengeGroupInfo* season = data::Tables::get().challengeGroup(req.group_id);
data::ChallengeKind kind = season != nullptr ? season->kind : data::ChallengeKind::Memory;
switch (kind) {
case data::ChallengeKind::Story:
rsp.challenge_story.emplace();
rsp.EDKOHAAMONH_case = proto::GetChallengeGroupStatisticsScRsp::k_challenge_story;
break;
case data::ChallengeKind::Boss:
rsp.challenge_boss.emplace();
rsp.EDKOHAAMONH_case = proto::GetChallengeGroupStatisticsScRsp::k_challenge_boss;
break;
case data::ChallengeKind::Memory:
rsp.challenge_default.emplace();
rsp.EDKOHAAMONH_case = proto::GetChallengeGroupStatisticsScRsp::k_challenge_default;
break;
}
session.send(cmd::GetChallengeGroupStatisticsScRsp, rsp);
Player* player = playerOf(session, "GetChallengeGroupStatistics");
if (player == nullptr) return;
session.send(cmd::GetChallengeGroupStatisticsScRsp, challenge::statistics(*player, req.group_id));
}
void onTakeChallengeReward(net::Session& session, const proto::TakeChallengeRewardCsReq& req) {
// The history already reports every reward as taken, so there is nothing left to
// hand over -- but the request still has to complete or the screen hangs.
proto::TakeChallengeRewardScRsp rsp;
rsp.retcode = 0;
rsp.group_id = req.group_id;
Player* player = playerOf(session, "TakeChallengeReward");
if (player == nullptr) return;
std::vector<data::ItemStack> granted;
proto::TakeChallengeRewardScRsp rsp = challenge::takeRewards(*player, req.group_id, granted);
if (!granted.empty()) {
session.send(cmd::PlayerSyncScNotify, inventory::sync(*player, granted));
player->saveNow();
}
session.send(cmd::TakeChallengeRewardScRsp, rsp);
}
+17
View File
@@ -43,6 +43,15 @@ struct Inventory {
std::map<uint32_t, uint32_t> items;
};
// The best clear of one challenge floor. Saved; the season statistics panel shows it.
struct ChallengeRecord {
uint32_t stars = 0; // bitmask over the floor's targets
uint32_t roundsUsed = 0;
uint32_t score = 0;
uint32_t buffs[2] = {0, 0};
std::vector<uint32_t> teams[2];
};
// 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 {
@@ -191,6 +200,12 @@ public:
const PeakProgress& peakProgress() const { return peakProgress_; }
GachaProgress& gacha() { return gacha_; }
const GachaProgress& gacha() const { return gacha_; }
// Keyed by floor id.
std::map<uint32_t, ChallengeRecord>& challengeRecords() { return challengeRecords_; }
const std::map<uint32_t, ChallengeRecord>& challengeRecords() const { return challengeRecords_; }
// Season id -> bitmask over the star counts whose reward has been claimed.
std::map<uint32_t, uint64_t>& challengeRewardsTaken() { return challengeRewardsTaken_; }
const std::map<uint32_t, uint64_t>& challengeRewardsTaken() const { return challengeRewardsTaken_; }
uint32_t mainCharacter() const { return mainCharacter_; }
void setMainCharacter(uint32_t value) { mainCharacter_ = value; }
@@ -230,6 +245,8 @@ private:
TierceRun tierce_;
std::map<uint32_t, TierceProgress> tierceHistory_;
GachaProgress gacha_;
std::map<uint32_t, ChallengeRecord> challengeRecords_;
std::map<uint32_t, uint64_t> challengeRewardsTaken_;
uint32_t mainCharacter_ = 8008;
uint32_t marchType_ = 1224;
+46
View File
@@ -214,6 +214,35 @@ bool loadPlayerState(Player& player) {
}
}
}
if (auto floors = j.find("challenge_records"); floors != j.end() && floors->is_array()) {
for (const json& entry : *floors) {
uint32_t id = entry.is_object() ? u32(entry, "id", 0) : 0;
if (id == 0) continue;
ChallengeRecord& record = player.challengeRecords()[id];
record.stars = u32(entry, "stars", 0);
record.roundsUsed = u32(entry, "rounds_used", 0);
record.score = u32(entry, "score", 0);
std::vector<uint32_t> buffs = u32Array(entry, "buffs");
for (size_t i = 0; i < buffs.size() && i < 2; ++i) record.buffs[i] = buffs[i];
if (auto teams = entry.find("teams"); teams != entry.end() && teams->is_array()) {
for (size_t i = 0; i < teams->size() && i < 2; ++i) {
for (const json& avatar : (*teams)[i]) {
if (avatar.is_number_unsigned()) record.teams[i].push_back(avatar.get<uint32_t>());
}
}
}
}
}
if (auto taken = j.find("challenge_rewards_taken"); taken != j.end() && taken->is_array()) {
for (const json& entry : *taken) {
uint32_t group = entry.is_object() ? u32(entry, "group", 0) : 0;
auto stars = entry.is_object() ? entry.find("stars") : entry.end();
if (group == 0 || stars == entry.end() || !stars->is_number_unsigned()) continue;
player.challengeRewardsTaken()[group] = stars->get<uint64_t>();
}
}
return true;
}
@@ -313,6 +342,23 @@ std::string playerStateJson(const Player& player) {
{"mcoin", bag.mcoin},
{"items", items}};
json floors = json::array();
for (const auto& [id, record] : player.challengeRecords()) {
floors.push_back({{"id", id},
{"stars", record.stars},
{"rounds_used", record.roundsUsed},
{"score", record.score},
{"buffs", json::array({record.buffs[0], record.buffs[1]})},
{"teams", json::array({record.teams[0], record.teams[1]})}});
}
j["challenge_records"] = floors;
json taken = json::array();
for (const auto& [group, stars] : player.challengeRewardsTaken()) {
taken.push_back({{"group", group}, {"stars", stars}});
}
j["challenge_rewards_taken"] = taken;
return j.dump(2);
}
+8
View File
@@ -1096,6 +1096,14 @@ void runFlowTests() {
bagAfter.material_list.size() > 2,
"the drops are in the bag");
proto::TakeChallengeRewardCsReq claim;
claim.group_id = 100;
client.send(cmd::TakeChallengeRewardCsReq, claim);
proto::TakeChallengeRewardScRsp claimed;
check(client.await(cmd::TakeChallengeRewardScRsp, packet) && parseBody(packet, claimed) &&
claimed.retcode == 0 && !claimed.taken_reward_list.empty(),
"season rewards can be claimed");
// 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);
+42 -2
View File
@@ -491,13 +491,53 @@ void testInventory() {
sync.material_list[0].num == 3 && sync.basic_info.has(),
"a sync names bag totals and carries the wallet");
// Season rewards: paid once, and the history marks them.
bool unlocked = core::Config::get().gameplay.unlockAllChallenges;
core::Config::get().gameplay.unlockAllChallenges = true;
uint32_t before = bag.hcoin;
std::vector<data::ItemStack> granted;
proto::TakeChallengeRewardScRsp first = game::challenge::takeRewards(player, 100, granted);
check(!first.taken_reward_list.empty() && !granted.empty() && bag.hcoin > before,
"a cleared season's rewards pay out");
std::vector<data::ItemStack> again;
proto::TakeChallengeRewardScRsp second = game::challenge::takeRewards(player, 100, again);
check(second.taken_reward_list.empty() && again.empty(), "and only once");
const data::ChallengeGroupInfo* season = tables.challengeGroup(100);
uint64_t line = season != nullptr ? tables.challengeRewardStars(season->rewardLineGroupId) : 0;
uint64_t marked = 0;
for (const proto::ChallengeGroup& group : game::challenge::history(&player).challenge_group_list) {
if (group.group_id == 100) marked = group.taken_stars_count_reward;
}
check(marked != 0 && (marked & ~line) == 0, "the history marks what was claimed");
core::Config::get().gameplay.unlockAllChallenges = unlocked;
// A cleared floor shows in its season's statistics.
game::ChallengeRecord& record = player.challengeRecords()[1];
record.stars = 7;
record.roundsUsed = 12;
record.teams[0] = {1001, 1002};
record.teams[1] = {1003, 1004};
proto::GetChallengeGroupStatisticsScRsp stats = game::challenge::statistics(player, 100);
check(stats.challenge_default.has() && stats.challenge_default->record_id == 1 &&
stats.challenge_default->PPBHLLOJNEK.has() &&
stats.challenge_default->PPBHLLOJNEK->EEJCPNAEKLJ == 3 &&
stats.challenge_default->PPBHLLOJNEK->round_count == 12 &&
stats.challenge_default->PPBHLLOJNEK->lineup_list.size() == 2,
"a season's best clear shows in its statistics");
proto::GetChallengeGroupStatisticsScRsp none = game::challenge::statistics(player, 101);
check(none.challenge_default.has() && !none.challenge_default->PPBHLLOJNEK.has(),
"and a season with no clear shows none");
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");
check(loaded.inventory().items[211] == 3 && loaded.inventory().reserveStamina == bag.reserveStamina &&
loaded.challengeRecords().count(1) == 1 &&
loaded.challengeRecords().at(1).teams[1].size() == 2 &&
!loaded.challengeRewardsTaken().empty(),
"bag, records and claimed rewards survive a restart");
core::Config::get().paths.playerFile = realPath;
}