diff --git a/src/game/challenge.cpp b/src/game/challenge.cpp index e10d789..d480a33 100644 --- a/src/game/challenge.cpp +++ b/src/game/challenge.cpp @@ -1,11 +1,13 @@ #include "game/challenge.h" #include +#include #include #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 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& 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* 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 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 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& out) { + for (const std::vector& 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(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(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(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; diff --git a/src/game/challenge.h b/src/game/challenge.h index df97a8f..0cfe777 100644 --- a/src/game/challenge.h +++ b/src/game/challenge.h @@ -1,7 +1,9 @@ #pragma once #include +#include +#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& 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); diff --git a/src/game/challenge_handlers.cpp b/src/game/challenge_handlers.cpp index 73ab078..1699c05 100644 --- a/src/game/challenge_handlers.cpp +++ b/src/game/challenge_handlers.cpp @@ -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 teamOf(const std::vector& 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 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); } diff --git a/src/game/player.h b/src/game/player.h index 88b9e4f..d26cff5 100644 --- a/src/game/player.h +++ b/src/game/player.h @@ -43,6 +43,15 @@ struct Inventory { std::map 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 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& challengeRecords() { return challengeRecords_; } + const std::map& challengeRecords() const { return challengeRecords_; } + // Season id -> bitmask over the star counts whose reward has been claimed. + std::map& challengeRewardsTaken() { return challengeRewardsTaken_; } + const std::map& 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 tierceHistory_; GachaProgress gacha_; + std::map challengeRecords_; + std::map challengeRewardsTaken_; uint32_t mainCharacter_ = 8008; uint32_t marchType_ = 1224; diff --git a/src/game/player_store.cpp b/src/game/player_store.cpp index 0576196..95116c1 100644 --- a/src/game/player_store.cpp +++ b/src/game/player_store.cpp @@ -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 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()); + } + } + } + } + } + + 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(); + } + } 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); } diff --git a/tests/test_flow.cpp b/tests/test_flow.cpp index 80e79c0..7429b3e 100644 --- a/tests/test_flow.cpp +++ b/tests/test_flow.cpp @@ -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); diff --git a/tests/test_game.cpp b/tests/test_game.cpp index 60d5f88..9bfbdba 100644 --- a/tests/test_game.cpp +++ b/tests/test_game.cpp @@ -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 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 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; }