diff --git a/config/config.json b/config/config.json index abe5b0b..44b9868 100644 --- a/config/config.json +++ b/config/config.json @@ -1,8 +1,16 @@ { "server": { "name": "CapySR", - "http": { "bind": "0.0.0.0", "port": 21000, "public_host": "127.0.0.1" }, - "game": { "bind": "0.0.0.0", "port": 23301, "public_host": "127.0.0.1" } + "http": { + "bind": "0.0.0.0", + "port": 21000, + "public_host": "127.0.0.1" + }, + "game": { + "bind": "0.0.0.0", + "port": 23301, + "public_host": "127.0.0.1" + } }, "log": { "level": "debug", @@ -35,7 +43,8 @@ "global_buffs": true, "unlock_all_challenges": true, "main_character": 8008, - "march_type": 1224 + "march_type": 1224, + "skip_missions": [] }, "paths": { "srtools_file": "data/freesr-data.json", diff --git a/proto/seeds.txt b/proto/seeds.txt index 47a8ac7..6dec7d3 100644 --- a/proto/seeds.txt +++ b/proto/seeds.txt @@ -140,6 +140,12 @@ UpdateGroupPropertyCsReq UpdateGroupPropertyScRsp GetFirstTalkNpcCsReq GetFirstTalkNpcScRsp +GetFirstTalkByPerformanceNpcCsReq +GetFirstTalkByPerformanceNpcScRsp +FinishFirstTalkNpcCsReq +FinishFirstTalkNpcScRsp +FinishFirstTalkByPerformanceNpcCsReq +FinishFirstTalkByPerformanceNpcScRsp GetNpcTakenRewardCsReq GetNpcTakenRewardScRsp ActiveFarmElementCsReq @@ -195,6 +201,47 @@ TakeChallengeRewardScRsp ChallengeLineupNotify ChallengeSettleNotify +# ---- three-node challenge floors (memory of chaos and its siblings, 4.6) ---- +GetChallengeTierceDataCsReq +GetChallengeTierceDataScRsp +GetChallengeTierceControllerCsReq +GetChallengeTierceControllerScRsp +SetChallengeTierceLineupCsReq +SetChallengeTierceLineupScRsp +StartChallengeTierceCsReq +StartChallengeTierceScRsp +StartNextChallengeTierceCsReq +StartNextChallengeTierceScRsp +RestartChallengeTierceCsReq +RestartChallengeTierceScRsp +ConfirmChallengeTierceStageSettleCsReq +ConfirmChallengeTierceStageSettleScRsp +LeaveChallengeTierceCsReq +LeaveChallengeTierceScRsp +ChallengeTierceSyncNotify + +# ---- anomaly arbitration ---- +GetChallengePeakDataCsReq +GetChallengePeakDataScRsp +GetCurChallengePeakCsReq +GetCurChallengePeakScRsp +SetChallengePeakMobLineupAvatarCsReq +SetChallengePeakMobLineupAvatarScRsp +SetChallengePeakBossHardModeCsReq +SetChallengePeakBossHardModeScRsp +StartChallengePeakCsReq +StartChallengePeakScRsp +ReStartChallengePeakCsReq +ReStartChallengePeakScRsp +LeaveChallengePeakCsReq +LeaveChallengePeakScRsp +ConfirmChallengePeakSettleCsReq +ConfirmChallengePeakSettleScRsp +TakeChallengePeakRewardCsReq +TakeChallengePeakRewardScRsp +ChallengePeakGroupDataUpdateScNotify +ChallengePeakSettleScNotify + # ---- inventory ---- GetBagCsReq GetBagScRsp diff --git a/src/core/config.cpp b/src/core/config.cpp index 338c518..b4b764d 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -104,6 +104,7 @@ bool Config::load(const std::string& path) { pick(*it, "unlock_all_challenges", gameplay.unlockAllChallenges); pick(*it, "main_character", gameplay.mainCharacter); pick(*it, "march_type", gameplay.marchType); + pick(*it, "skip_missions", gameplay.skipMissions); } return true; } diff --git a/src/core/config.h b/src/core/config.h index daac1e9..b7646d4 100644 --- a/src/core/config.h +++ b/src/core/config.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -58,8 +59,8 @@ struct PathsConfig { }; struct GameplayConfig { - // auto = a srtools build replaces overworld fights; srtools = it replaces every - // fight; stage = never, always use the stage the client asked for. + // auto = a srtools build replaces calyx fights and nothing else; srtools = it + // replaces every fight; stage = never, always use the stage the client asked for. std::string battleSource = "auto"; bool globalBuffs = true; // Report every challenge floor as a full clear. A floor with no stars locks the one @@ -67,6 +68,16 @@ struct GameplayConfig { bool unlockAllChallenges = true; uint32_t mainCharacter = 8008; uint32_t marchType = 1224; + // Missions never reported as finished. A scene group gated on one of these makes + // the 4.5.54 client throw inside MissionModule._CheckVerseByMainMission while it + // works out which groups to activate, which kills the whole map load: a hang on the + // loading screen when entering at login, a fade that rolls straight back otherwise. + // Main and sub mission ids both go here. + std::vector skipMissions; + + bool missionSkipped(uint32_t id) const { + return std::find(skipMissions.begin(), skipMissions.end(), id) != skipMissions.end(); + } }; struct Config { diff --git a/src/data/excel.cpp b/src/data/excel.cpp index 8b8c4c8..5df2983 100644 --- a/src/data/excel.cpp +++ b/src/data/excel.cpp @@ -226,6 +226,23 @@ const T* findById(const std::vector& sorted, uint32_t id) { const ChallengeInfo* Tables::challenge(uint32_t id) const { return findById(challenges_, id); } +const PeakGroupInfo* Tables::peakGroup(uint32_t id) const { return findById(peakGroups_, id); } + +const PeakInfo* Tables::peak(uint32_t id) const { + auto it = peaks_.find(id); + return it == peaks_.end() ? nullptr : &it->second; +} + +const std::vector* Tables::peakRewards(uint32_t rewardGroupId) const { + auto it = peakRewards_.find(rewardGroupId); + return it == peakRewards_.end() ? nullptr : &it->second; +} + +const BattleTargetInfo* Tables::battleTarget(uint32_t id) const { + auto it = battleTargets_.find(id); + return it == battleTargets_.end() ? nullptr : &it->second; +} + const ChallengeGroupInfo* Tables::challengeGroup(uint32_t groupId) const { return findById(challengeGroups_, groupId); } @@ -235,6 +252,18 @@ const ChallengeTarget* Tables::challengeTarget(uint32_t id) const { return it == challengeTargets_.end() ? nullptr : &it->second; } +const ChallengeTierceInfo* Tables::challengeTierce(uint32_t id) const { + auto it = challengeTierces_.find(id); + return it == challengeTierces_.end() ? nullptr : &it->second; +} + +const ChallengeTierceInfo* Tables::challengeTierceFor(uint32_t challengeId) const { + for (const auto& [id, info] : challengeTierces_) { + if (info.preChallengeId == challengeId) return &info; + } + return nullptr; +} + uint64_t Tables::challengeRewardStars(uint32_t rewardLineGroupId) const { auto it = challengeRewardStars_.find(rewardLineGroupId); return it == challengeRewardStars_.end() ? 0 : it->second; @@ -545,6 +574,31 @@ bool Tables::load(const std::vector& sources) { challengeFloors("ChallengeStoryMazeConfig.json", ChallengeKind::Story); challengeFloors("ChallengeBossMazeConfig.json", ChallengeKind::Boss); + // The third node, beta-only so far. GNOOAGPBNLD is the floor-wide cycle limit; + // only the Memory rows carry one. + for (const json* row : table("ChallengeMazeTierceConfig.json")) { + uint32_t id = u32(*row, "ID"); + if (id == 0 || challengeTierces_.count(id) != 0) continue; + ChallengeTierceInfo info; + info.id = id; + info.preChallengeId = u32(*row, "PreChallengeMazeID"); + info.mapEntranceId = u32(*row, "MapEntranceID"); + info.mazeGroupId = u32(*row, "MazeGroupID"); + info.roundLimit = u32(*row, "GNOOAGPBNLD"); + info.targetIds = u32List(*row, "ChallengeTargetID"); + std::vector configs = u32List(*row, "ConfigList"); + std::vector npcs = u32List(*row, "NpcMonsterIDList"); + std::vector events = u32List(*row, "EventIDList"); + for (size_t i = 0; i < configs.size(); ++i) { + ChallengeMonster monster; + monster.configId = configs[i]; + monster.npcMonsterId = i < npcs.size() ? npcs[i] : 0; + monster.eventId = i < events.size() ? events[i] : 0; + info.monsters.push_back(monster); + } + if (info.preChallengeId != 0) challengeTierces_.emplace(id, std::move(info)); + } + // Pure Fiction keeps its turn limit and its score targets one table over. for (const json* row : table("ChallengeStoryMazeExtra.json")) { uint32_t id = u32(*row, "ID"); @@ -594,6 +648,61 @@ bool Tables::load(const std::vector& sources) { challengeRewards("ChallengeStoryRewardLine.json"); challengeRewards("ChallengeBossRewardLine.json"); + // Anomaly Arbitration. Only the beta dump places the fights -- production rows + // have no arena columns -- so a later source fills what an earlier one left + // empty rather than losing the row to it. The last entry of each list is the + // encounter itself. + for (const json* row : table("ChallengePeakConfig.json")) { + uint32_t id = u32(*row, "ID"); + if (id == 0) continue; + PeakInfo& peak = peaks_[id]; + peak.id = id; + if (peak.mapEntranceId == 0) peak.mapEntranceId = u32(*row, "MapEntranceID"); + if (peak.mazeGroupId == 0) peak.mazeGroupId = u32(*row, "MazeGroupID"); + if (peak.npcMonsterId == 0) { + std::vector npcs = u32List(*row, "NpcMonsterIDList"); + if (!npcs.empty()) peak.npcMonsterId = npcs.back(); + } + if (peak.eventId == 0) { + std::vector events = u32List(*row, "EventIDList"); + if (!events.empty()) peak.eventId = events.back(); + } + if (peak.targetIds.empty()) peak.targetIds = u32List(*row, "NormalTargetList"); + if (peak.tagBuffs.empty()) peak.tagBuffs = u32List(*row, "TagList"); + } + for (const json* row : table("ChallengePeakBossConfig.json")) { + uint32_t id = u32(*row, "ID"); + if (id == 0) continue; + PeakInfo& peak = peaks_[id]; + if (peak.boss) continue; + peak.id = id; + peak.boss = true; + peak.bossBuffs = u32List(*row, "BuffList"); + peak.hardTarget = u32(*row, "HardTarget"); + std::vector events = u32List(*row, "HardEventIDList"); + if (!events.empty()) peak.hardEventId = events.back(); + peak.hardTagBuffs = u32List(*row, "HardTagList"); + } + for (const json* row : table("ChallengePeakGroupConfig.json")) { + uint32_t id = u32(*row, "ID"); + if (id == 0) continue; + peakGroups_.push_back({id, u32List(*row, "PreLevelIDList"), u32(*row, "BossLevelID"), + u32(*row, "RewardGroupID")}); + } + for (const json* row : table("ChallengePeakReward.json")) { + uint32_t id = u32(*row, "ID"); + uint32_t group = u32(*row, "RewardGroupID"); + if (id == 0 || group == 0) continue; + std::vector& ids = peakRewards_[group]; + if (std::find(ids.begin(), ids.end(), id) == ids.end()) ids.push_back(id); + } + for (const json* row : table("BattleTargetConfig.json")) { + uint32_t id = u32(*row, "ID"); + if (id == 0 || battleTargets_.count(id) != 0) continue; + bool deaths = str(*row, "AbilityName").find("DeathCount") != std::string::npos; + battleTargets_.emplace(id, BattleTargetInfo{id, u32(*row, "TargetParam"), deaths}); + } + for (const json* row : table("InteractConfig.json")) { uint32_t id = u32(*row, "InteractID"); if (id == 0 || interacts_.count(id) != 0) continue; @@ -634,6 +743,21 @@ bool Tables::load(const std::vector& sources) { }; sortById(challenges_); sortById(challengeGroups_); + sortById(peakGroups_); + + // Each fight learns its season, and plants its one monster over the arena's marker. + for (const PeakGroupInfo& group : peakGroups_) { + for (uint32_t id : group.mobIds) { + if (auto it = peaks_.find(id); it != peaks_.end()) it->second.groupId = group.id; + } + if (auto it = peaks_.find(group.bossId); it != peaks_.end()) it->second.groupId = group.id; + } + for (auto& [id, peak] : peaks_) { + if (peak.eventId != 0) peak.monsters = {{kPeakMarkerId, peak.npcMonsterId, peak.eventId}}; + if (peak.hardEventId != 0) { + peak.hardMonsters = {{kPeakMarkerId, peak.npcMonsterId, peak.hardEventId}}; + } + } for (ChallengeInfo& floor : challenges_) { auto extra = challengeExtras_.find(floor.id); @@ -653,8 +777,8 @@ bool Tables::load(const std::vector& sources) { "{} plane events from {} source(s) in {} ms", avatars_.size(), skillPointCount_, stages_.size(), monsters_.size(), entrances_.size(), planeEvents_.size(), used, util::nowMs() - started); - logging::info("data", "{} challenge floors in {} seasons", - challenges_.size(), challengeGroups_.size()); + logging::info("data", "{} challenge floors in {} seasons, {} of them with a third node", + challenges_.size(), challengeGroups_.size(), challengeTierces_.size()); logging::info("data", "{} main missions, {} tutorials, {} guides, {} quests", mainMissions_.size(), tutorials_.size(), tutorialGuides_.size(), quests_.size()); diff --git a/src/data/excel.h b/src/data/excel.h index baadb75..99d546e 100644 --- a/src/data/excel.h +++ b/src/data/excel.h @@ -127,6 +127,20 @@ struct ChallengeInfo { ChallengeStage stages[2]; }; +// The third node 4.6 bolts onto a challenge floor. It brings its own arena, monster and +// star targets; the buff, the mode and the first two nodes come from the floor it +// extends. The client starts the whole floor through this once a row exists for it. +struct ChallengeTierceInfo { + uint32_t id = 0; + uint32_t preChallengeId = 0; // PreChallengeMazeID, the floor this extends + uint32_t mapEntranceId = 0; + uint32_t mazeGroupId = 0; + // Cycles for the whole floor, MoC only; the other two keep the floor's own limit. + uint32_t roundLimit = 0; + std::vector targetIds; + std::vector monsters; +}; + // How one star is earned. MoC counts cycles left and deaths; the other two only score. struct ChallengeTarget { uint32_t id = 0; @@ -142,6 +156,47 @@ struct ChallengeGroupInfo { ChallengeKind kind = ChallengeKind::Memory; }; +// Every Anomaly Arbitration arena keeps its one monster at this instance id. +constexpr uint32_t kPeakMarkerId = 200001; + +// One Anomaly Arbitration fight: a knight, or the season's boss. Like the other +// challenges it is fought on an ordinary floor cut down to one maze group, where a +// single monster starts the stage its event names. +struct PeakInfo { + uint32_t id = 0; + uint32_t groupId = 0; + uint32_t mapEntranceId = 0; + uint32_t mazeGroupId = 0; + uint32_t npcMonsterId = 0; + uint32_t eventId = 0; + std::vector targetIds; // NormalTargetList + std::vector tagBuffs; // the enemy tags, TagList + std::vector monsters; + // The boss only: the buffs the player picks one of, and what hard mode swaps in. + bool boss = false; + std::vector bossBuffs; + uint32_t hardTarget = 0; + uint32_t hardEventId = 0; + std::vector hardTagBuffs; + std::vector hardMonsters; +}; + +// One Anomaly Arbitration season: three knights and a boss. +struct PeakGroupInfo { + uint32_t id = 0; + std::vector mobIds; + uint32_t bossId = 0; + uint32_t rewardGroupId = 0; +}; + +// BattleTargetConfig, as far as a result is judged by it: a ceiling on the cycles used, +// or on the avatars lost. +struct BattleTargetInfo { + uint32_t id = 0; + uint32_t param = 0; + bool countsDeaths = false; +}; + // MazePlane.PlaneType strings, in the numbering SceneInfo.game_mode_type uses. uint32_t planeTypeFromName(const std::string& name); @@ -205,6 +260,9 @@ public: // Every floor and season of the three challenge modes, in id order. const std::vector& challenges() const { return challenges_; } + // Keyed by the tierce's own id, and by the floor it extends. + const ChallengeTierceInfo* challengeTierce(uint32_t id) const; + const ChallengeTierceInfo* challengeTierceFor(uint32_t challengeId) const; const std::vector& challengeGroups() const { return challengeGroups_; } const ChallengeInfo* challenge(uint32_t id) const; const ChallengeGroupInfo* challengeGroup(uint32_t groupId) const; @@ -212,6 +270,14 @@ public: // Bit n set for every star count the reward line of that group pays out at. uint64_t challengeRewardStars(uint32_t rewardLineGroupId) const; + // Anomaly Arbitration seasons in id order, and the fights they are made of. + const std::vector& peakGroups() const { return peakGroups_; } + const PeakGroupInfo* peakGroup(uint32_t id) const; + const PeakInfo* peak(uint32_t id) const; + // ChallengePeakReward rows paid out by one reward group. + const std::vector* peakRewards(uint32_t rewardGroupId) const; + const BattleTargetInfo* battleTarget(uint32_t id) const; + const std::unordered_map& avatars() const { return avatars_; } const std::unordered_map& entrances() const { return entrances_; } @@ -240,6 +306,7 @@ private: std::vector tutorialGuides_; std::vector quests_; std::vector challenges_; + std::unordered_map challengeTierces_; std::vector challengeGroups_; std::unordered_map challengeTargets_; // ChallengeStoryMazeExtra, held only until load() folds it into the floors. @@ -249,6 +316,10 @@ private: }; std::unordered_map challengeExtras_; std::unordered_map challengeRewardStars_; + std::vector peakGroups_; + std::unordered_map peaks_; + std::unordered_map> peakRewards_; + std::unordered_map battleTargets_; size_t skillPointCount_ = 0; bool loaded_ = false; }; diff --git a/src/game/battle.cpp b/src/game/battle.cpp index 9f5ccfc..d6b2ce8 100644 --- a/src/game/battle.cpp +++ b/src/game/battle.cpp @@ -56,7 +56,7 @@ bool useSrToolsBattle(const BattleConfig& config, bool allowOverride) { const std::string& source = core::Config::get().gameplay.battleSource; if (source == "stage") return false; if (source == "srtools") return true; - return allowOverride; // "auto" + return allowOverride; // "auto": the calyx only } // Battle types that need a win condition of their own, or the client hangs on the @@ -82,6 +82,17 @@ void addBattleTargets(proto::SceneBattleInfo& info, const std::string& battleTyp } } else if (battleType == "AS") { target(1, 90005); + } else if (battleType == "AA") { + // Every slot present; the fight's own targets go in the fifth, each with the + // ceiling it is judged against. + for (uint32_t key = 1; key <= 4; ++key) info.battle_target_info[key]; + for (uint32_t id : targets) { + const data::BattleTargetInfo* config = data::Tables::get().battleTarget(id); + proto::BattleTarget entry; + entry.id = id; + entry.total_progress = config != nullptr ? config->param : 0; + info.battle_target_info[5].battle_target_list.push_back(entry); + } } } @@ -165,16 +176,19 @@ proto::SceneBattleInfo create(Player& player, const BattleRequest& request) { if (useSrToolsBattle(config, request.allowSrToolsOverride)) { info.stage_id = config.stageId; info.rounds_limit = config.cycleCount; - for (const std::vector& wave : config.waves) { - std::vector ids; - uint32_t level = 1; - for (const BattleMonster& monster : wave) { - for (uint32_t n = 0; n < std::max(1u, monster.amount); ++n) { - ids.push_back(monster.monsterId); + // A calyx sweep buys several runs, one fight each, so the build's waves repeat. + for (uint32_t run = 0; run < std::max(1u, request.wave); ++run) { + for (const std::vector& wave : config.waves) { + std::vector ids; + uint32_t level = 1; + for (const BattleMonster& monster : wave) { + for (uint32_t n = 0; n < std::max(1u, monster.amount); ++n) { + ids.push_back(monster.monsterId); + } + level = std::max(level, monster.level); } - level = std::max(level, monster.level); + addWave(info, config.stageId, ids, level); } - addWave(info, config.stageId, ids, level); } for (const BattleBuff& blessing : config.blessings) { proto::BattleBuff buff = makeBuff(blessing.id, blessing.level, kNoOwner); @@ -230,6 +244,9 @@ proto::SceneBattleInfo create(Player& player, const BattleRequest& request) { for (uint32_t buffId : {request.mazeBuffId, request.stageBuffId}) { if (buffId != 0) info.buff_list.push_back(makeBuff(buffId, 1, kNoOwner)); } + for (uint32_t buffId : request.floorBuffIds) { + if (buffId != 0) info.buff_list.push_back(makeBuff(buffId, 1, kNoOwner)); + } if (!request.battleType.empty()) { addBattleTargets(info, request.battleType, request.battleTargetIds, request.scoreSoFar); } diff --git a/src/game/battle.h b/src/game/battle.h index 17dd1d1..8413f40 100644 --- a/src/game/battle.h +++ b/src/game/battle.h @@ -20,8 +20,9 @@ struct BattleRequest { std::vector stageIds; uint32_t cocoonId = 0; uint32_t wave = 0; - // Calyx and challenges keep their own stage even when a srtools build is loaded. - bool allowSrToolsOverride = true; + // 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; // ---- challenges ---------------------------------------------------------- // The team fighting this node, when it is not the squad the player walks around @@ -30,10 +31,12 @@ struct BattleRequest { // The floor's own maze buff, and the one the player picked for this half. uint32_t mazeBuffId = 0; uint32_t stageBuffId = 0; + // Anomaly Arbitration's enemy tags and the boss buff the player picked. + std::vector floorBuffIds; // MoC's cycle limit, counted down across the whole floor. uint32_t roundsLimit = 0; - // "PF" or "AS": the two scored modes need a win condition of their own or the - // client hangs on the last turn with nothing to settle. + // "PF", "AS" or "AA": modes that need a win condition of their own, or the client + // hangs on the last turn with nothing to settle. std::string battleType; // Score carried in from the first half, and Pure Fiction's per-wave targets. uint32_t scoreSoFar = 0; diff --git a/src/game/battle_handlers.cpp b/src/game/battle_handlers.cpp index f207261..f326058 100644 --- a/src/game/battle_handlers.cpp +++ b/src/game/battle_handlers.cpp @@ -7,8 +7,10 @@ #include "game/challenge.h" #include "game/handlers.h" #include "game/notify.h" +#include "game/peak.h" #include "game/player.h" #include "game/scene.h" +#include "game/tierce.h" #include "net/cmd_ids.h" #include "net/handler.h" #include "net/session.h" @@ -30,7 +32,7 @@ bool clearsFodder(const Player& player, uint32_t skillIndex, uint32_t casterEnti if (skillIndex == 0 || casterEntityId == 0) return false; // A challenge node is not cleared by walking through it, and the caster would be // looked up in the overworld squad rather than the challenge team. - if (player.challenge().active) return false; + if (player.challenge().active || player.peak().active || player.tierce().active) return false; std::vector party = player.lineups().curMembers(); if (casterEntityId > party.size()) return false; uint32_t avatarId = party[casterEntityId - 1]; @@ -62,7 +64,6 @@ void applyChallenge(const Player& player, BattleRequest& request) { request.roundsLimit = run.roundsLeft; request.scoreSoFar = run.totalScore(); request.battleTargetIds = config->battleTargetIds; - request.allowSrToolsOverride = false; switch (config->kind) { case data::ChallengeKind::Story: request.battleType = "PF"; @@ -153,6 +154,8 @@ void onSceneCastSkill(net::Session& session, const proto::SceneCastSkillCsReq& r request.skillIndex = req.skill_index; request.monsterEntityIds = monsters; applyChallenge(*player, request); + tierce::prepareBattle(*player, request); + peak::prepareBattle(*player, request); rsp.battle_info = battle::create(*player, request); report(monsters, proto::MonsterBattleType::MONSTER_BATTLE_TYPE_TRIGGER_BATTLE); session.send(cmd::SceneCastSkillScRsp, rsp); @@ -178,14 +181,14 @@ void onSceneCastSkillCostMp(net::Session& session, const proto::SceneCastSkillCo session.send(cmd::SceneCastSkillCostMpScRsp, rsp); } -// Calyx and the other farm stages: the cocoon names the stage, so a srtools build -// must not replace it. `wave` is how many runs were bought, one stage each. +// 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 request; request.cocoonId = cocoonId; request.wave = std::max(1u, wave); - request.allowSrToolsOverride = false; + request.allowSrToolsOverride = true; const data::CocoonInfo* cocoon = data::Tables::get().cocoon(cocoonId, worldLevel); if (cocoon == nullptr || cocoon->stageIds.empty()) { @@ -230,7 +233,7 @@ void onQuickStartCocoonStage(net::Session& session, } // A Stagnant Shadow started from the Survival Index. PAOFHFLFFHD is the shadow's stage, -// the key FarmElementSweep uses too; like a calyx, a srtools build must not replace it. +// the key FarmElementSweep uses too. void onQuickStartFarmElement(net::Session& session, const proto::QuickStartFarmElementCsReq& req) { Player* player = playerOf(session, "QuickStartFarmElement"); @@ -238,7 +241,6 @@ void onQuickStartFarmElement(net::Session& session, uint32_t worldLevel = req.world_level != 0 ? req.world_level : player->worldLevel(); BattleRequest request; - request.allowSrToolsOverride = false; uint32_t stageId = data::Tables::get().farmElementStage(req.PAOFHFLFFHD, worldLevel); if (stageId != 0) request.stageIds.push_back(stageId); @@ -318,10 +320,8 @@ void onReEnterLastElementStage(net::Session& session, Player* player = playerOf(session, "ReEnterLastElementStage"); if (player == nullptr) return; - // The retry button names the stage it wants replayed, so a srtools build must not - // substitute its own -- same rule as a calyx. + // The retry button names the stage it wants replayed. BattleRequest request; - request.allowSrToolsOverride = false; if (req.stage_id != 0) request.stageIds.push_back(req.stage_id); proto::ReEnterLastElementStageScRsp rsp; @@ -350,6 +350,8 @@ void onPveBattleResult(net::Session& session, const proto::PVEBattleResultCsReq& // The monsters are gone from the scene by now, which is how the run tells whether // the node it was fighting is finished. challenge::battleFinished(session, *player, req); + tierce::battleFinished(session, *player, req); + peak::battleFinished(session, *player, req); // The fight was simulated on the client; the server only confirms the outcome. proto::PVEBattleResultScRsp rsp; diff --git a/src/game/challenge.cpp b/src/game/challenge.cpp index 107d687..e10d789 100644 --- a/src/game/challenge.cpp +++ b/src/game/challenge.cpp @@ -81,15 +81,15 @@ proto::GetChallengeScRsp history() { return rsp; } -proto::LineupInfo lineup(const Player& player, uint32_t stage) { - const std::vector& party = player.challenge().party[half(stage)]; +proto::LineupInfo extraLineup(const Player& player, const std::vector& party, + proto::ExtraLineupType type) { Roster roster = player.roster(); proto::LineupInfo info; - info.extra_lineup_type = lineupTypeOf(stage); + info.extra_lineup_type = type; // An extra lineup is addressed as its type plus ten, which is how the client keeps // it out of the six ordinary squads. - info.index = static_cast(info.extra_lineup_type) + 10; + info.index = static_cast(type) + 10; info.max_mp = maxMp(party); info.mp = info.max_mp; info.leader_slot = 0; @@ -101,6 +101,10 @@ proto::LineupInfo lineup(const Player& player, uint32_t stage) { return info; } +proto::LineupInfo lineup(const Player& player, uint32_t stage) { + return extraLineup(player, player.challenge().party[half(stage)], lineupTypeOf(stage)); +} + proto::CurChallenge current(const Player& player) { const ChallengeRun& run = player.challenge(); const data::ChallengeInfo* config = configOf(player); @@ -124,9 +128,10 @@ proto::CurChallenge current(const Player& player) { const ChallengeArena* arena(const Player& player, ChallengeArena& storage) { const data::ChallengeInfo* config = configOf(player); if (config == nullptr) return nullptr; - const data::ChallengeStage& stage = config->stages[half(player.challenge().stage)]; - storage.mazeGroupId = stage.mazeGroupId; - storage.monsters = &stage.monsters; + uint32_t stage = player.challenge().stage; + storage.mazeGroupId = config->stages[half(stage)].mazeGroupId; + storage.monsters = &config->stages[half(stage)].monsters; + storage.party = &player.challenge().party[half(stage)]; return &storage; } @@ -288,14 +293,7 @@ bool nextPhase(net::Session& session, Player& player, proto::SceneInfo& out) { return true; } -void leave(net::Session& session, Player& player) { - ChallengeRun& run = player.challenge(); - if (!run.active) return; - - uint32_t entryId = run.origin.entryId; - Position at = run.originPos; - run = ChallengeRun{}; - +void putBack(net::Session& session, Player& player, uint32_t entryId, const Position& at) { proto::SceneInfo scene; if (!scene::load(player, entryId, 0, true, scene)) { logging::warn("challenge", "cannot put the player back at entry {}", entryId); @@ -315,5 +313,15 @@ void leave(net::Session& session, Player& player) { player.saveNow(); } +void leave(net::Session& session, Player& player) { + ChallengeRun& run = player.challenge(); + if (!run.active) return; + + uint32_t entryId = run.origin.entryId; + Position at = run.originPos; + run = ChallengeRun{}; + putBack(session, player, entryId, at); +} + } // namespace challenge } // namespace game diff --git a/src/game/challenge.h b/src/game/challenge.h index b67b450..df97a8f 100644 --- a/src/game/challenge.h +++ b/src/game/challenge.h @@ -49,5 +49,11 @@ bool nextPhase(net::Session& session, Player& player, proto::SceneInfo& out); // Ends the run and puts the player back where they started. void leave(net::Session& session, Player& player); +// Shared with Anomaly Arbitration, which fights with the same kind of team and leaves +// the same way. +proto::LineupInfo extraLineup(const Player& player, const std::vector& party, + proto::ExtraLineupType type); +void putBack(net::Session& session, Player& player, uint32_t entryId, const Position& at); + } // namespace challenge } // namespace game diff --git a/src/game/handlers.h b/src/game/handlers.h index 709e561..27d9fc9 100644 --- a/src/game/handlers.h +++ b/src/game/handlers.h @@ -19,7 +19,10 @@ void registerLineupHandlers(); void registerSceneHandlers(); void registerBattleHandlers(); void registerMissionHandlers(); +void registerTalkHandlers(); void registerChallengeHandlers(); +void registerTierceHandlers(); +void registerPeakHandlers(); void registerModuleHandlers(); void registerMiscHandlers(); diff --git a/src/game/misc_handlers.cpp b/src/game/misc_handlers.cpp index 9593d1a..5cdce91 100644 --- a/src/game/misc_handlers.cpp +++ b/src/game/misc_handlers.cpp @@ -12,6 +12,7 @@ #include "net/handler.h" #include "net/session.h" #include "proto/gen/protos.h" +#include "sdk/routes.h" namespace game { namespace { @@ -123,16 +124,17 @@ void onGetActivityScheduleConfig(net::Session& session, session.send(cmd::GetActivityScheduleConfigScRsp, rsp); } -// A "CapySR" tag on the client's version label: Lua in ClientDownloadDataScNotify that -// rewrites the VersionText object. Only the name -- the native watermark (gateway field -// 1660) stays off, it prints account data. +// A "CapySR" tag on the client's version label, with the client version after it: Lua in +// ClientDownloadDataScNotify that rewrites the VersionText object. The native watermark +// (gateway field 1660) stays off, it prints account data. constexpr std::string_view kWatermarkText = "CapySR"; constexpr uint32_t kWatermarkFrom = 0xD08C4F; // caramel constexpr uint32_t kWatermarkTo = 0xF6DDA0; // sand -std::string watermarkLua() { +// Caramel to sand across `text`, one colour tag per character. +std::string gradient(std::string_view text) { std::string rich; - size_t n = kWatermarkText.size(); + size_t n = text.size(); for (size_t i = 0; i < n; ++i) { double t = n > 1 ? static_cast(i) / static_cast(n - 1) : 0.0; auto channel = [&](int shift) { @@ -144,9 +146,29 @@ std::string watermarkLua() { std::snprintf(tag, sizeof(tag), "", channel(16), channel(8), channel(0)); rich += tag; - rich += kWatermarkText[i]; + rich += text[i]; rich += ""; } + return rich; +} + +// The version comes off a query string, so only what a version is made of gets into +// the Lua. +std::string cleanVersion(std::string_view version) { + std::string clean; + for (char c : version) { + bool keep = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + c == '.' || c == '_' || c == '-'; + if (keep) clean += c; + } + return clean; +} + +std::string watermarkLua(std::string_view version) { + // Name and version drawn alike, side by side. + std::string rich = gradient(kWatermarkText); + std::string clean = cleanVersion(version); + if (!clean.empty()) rich += " " + gradient("|") + " " + gradient(clean); return "pcall(function()\n" " local go = CS.UnityEngine.GameObject.Find(\"VersionText\")\n" " if go == nil then return end\n" @@ -166,7 +188,7 @@ void sendWatermark(net::Session& session) { auto& download = notify.download_data.emplace(); download.version = ++version; download.time = static_cast(util::nowMs() / 1000); - download.data = watermarkLua(); + download.data = watermarkLua(sdk::lastClientVersion()); session.send(cmd::ClientDownloadDataScNotify, notify); } @@ -200,7 +222,10 @@ void registerAllHandlers() { registerSceneHandlers(); registerBattleHandlers(); registerMissionHandlers(); + registerTalkHandlers(); registerChallengeHandlers(); + registerTierceHandlers(); + registerPeakHandlers(); registerModuleHandlers(); registerMiscHandlers(); logging::info("game", "{} packet handlers registered, {} answered empty", diff --git a/src/game/mission_handlers.cpp b/src/game/mission_handlers.cpp index df58b1d..15218c4 100644 --- a/src/game/mission_handlers.cpp +++ b/src/game/mission_handlers.cpp @@ -7,6 +7,7 @@ // // This is also what unlocks the world: the client gates map regions on finished main // missions. +#include "core/config.h" #include "core/logger.h" #include "data/excel.h" #include "game/handlers.h" @@ -18,9 +19,20 @@ namespace game { namespace { -void onGetMissionStatus(net::Session& session, const proto::GetMissionStatusCsReq& req) { - const data::Tables& tables = data::Tables::get(); +// Everything finished, bar the handful config names -- see gameplay.skip_missions. +std::vector finishedMainMissions() { + const core::GameplayConfig& gameplay = core::Config::get().gameplay; + const std::vector& all = data::Tables::get().mainMissions(); + if (gameplay.skipMissions.empty()) return all; + std::vector out; + out.reserve(all.size()); + for (uint32_t id : all) { + if (!gameplay.missionSkipped(id)) out.push_back(id); + } + return out; +} +void onGetMissionStatus(net::Session& session, const proto::GetMissionStatusCsReq& req) { proto::GetMissionStatusScRsp rsp; rsp.retcode = 0; // Whatever it asked about is finished. There are ~15k sub-missions in the tables, @@ -32,9 +44,9 @@ void onGetMissionStatus(net::Session& session, const proto::GetMissionStatusCsRe mission.progress = 1; rsp.sub_mission_status_list.push_back(mission); } - rsp.finished_main_mission_id_list = tables.mainMissions(); + rsp.finished_main_mission_id_list = finishedMainMissions(); // The client cross-checks this against the version it is running. - rsp.curversion_finished_main_mission_id_list = tables.mainMissions(); + rsp.curversion_finished_main_mission_id_list = rsp.finished_main_mission_id_list; session.send(cmd::GetMissionStatusScRsp, rsp); } @@ -43,7 +55,7 @@ void onGetMissionStatus(net::Session& session, const proto::GetMissionStatusCsRe void onGetMissionData(net::Session& session, const proto::GetMissionDataCsReq&) { proto::GetMissionDataScRsp rsp; rsp.retcode = 0; - rsp.finished_main_mission_id_list = data::Tables::get().mainMissions(); + rsp.finished_main_mission_id_list = finishedMainMissions(); session.send(cmd::GetMissionDataScRsp, rsp); } diff --git a/src/game/peak.cpp b/src/game/peak.cpp new file mode 100644 index 0000000..a08549c --- /dev/null +++ b/src/game/peak.cpp @@ -0,0 +1,359 @@ +#include "game/peak.h" + +#include +#include + +#include "core/config.h" +#include "core/logger.h" +#include "data/excel.h" +#include "data/scene_res.h" +#include "game/battle.h" +#include "game/challenge.h" +#include "game/handlers.h" +#include "game/player.h" +#include "net/cmd_ids.h" +#include "net/session.h" + +namespace game { +namespace { + +// Season one's arena. A season newer than the scene dump fights here instead: the +// floor is only a backdrop for one monster, and the stage comes from the fight. +constexpr uint32_t kFallbackEntrance = 3013501; +constexpr uint32_t kFallbackMazeGroup = 8; + +// The battle target slot a fight's own targets go in. +constexpr uint32_t kTargetSlot = 5; + +bool cleared() { return core::Config::get().gameplay.unlockAllChallenges; } + +const data::PeakInfo* configOf(const Player& player) { + const PeakRun& run = player.peak(); + if (!run.active) return nullptr; + return data::Tables::get().peak(run.peakId); +} + +// Hard mode judges the boss by one target of its own. +std::vector targetsOf(const data::PeakInfo& config, bool hard) { + if (hard && config.hardTarget != 0) return {config.hardTarget}; + return config.targetIds; +} + +// The most cycles a clear can take and still meet a target. +uint32_t turnBudget(const std::vector& targets) { + uint32_t budget = 0; + for (uint32_t id : targets) { + const data::BattleTargetInfo* target = data::Tables::get().battleTarget(id); + if (target != nullptr && !target->countsDeaths) budget = std::max(budget, target->param); + } + return budget; +} + +const std::vector& teamOf(const PeakProgress& progress, uint32_t peakId) { + static const std::vector kNone; + auto it = progress.teams.find(peakId); + return it == progress.teams.end() ? kNone : it->second; +} + +// The boss buff last picked, or the first on offer so the slot is never empty. +uint32_t bossBuffOf(const PeakProgress& progress, const data::PeakInfo& boss) { + auto it = progress.bossBuffs.find(boss.id); + if (it != progress.bossBuffs.end() && it->second != 0) return it->second; + return boss.bossBuffs.empty() ? 0 : boss.bossBuffs.front(); +} + +std::vector recordAvatars(const std::vector& team) { + std::vector out; + for (uint32_t avatarId : team) { + proto::ChallengePeakRecordAvatar entry; + entry.avatar_id = avatarId; + out.push_back(entry); + } + return out; +} + +proto::ChallengePeakBossClearance clearance(const PeakProgress& progress, + const data::PeakInfo& boss, bool hard) { + proto::ChallengePeakBossClearance out; + const PeakRecord* record = progress.record(boss.id, hard); + out.has_passed = record != nullptr || cleared(); + // Both difficulties show the same team and buff; the client fills its slots from them. + out.peak_avatar_id_list = teamOf(progress, boss.id); + out.buff_id = bossBuffOf(progress, boss); + if (record != nullptr) { + uint32_t budget = turnBudget(targetsOf(boss, hard)); + out.best_cycle_count = record->cycles; + out.best_record_buff_id = record->buffId; + out.max_left_turn = budget > record->cycles ? budget - record->cycles : 0; + out.best_record_avatar_list = recordAvatars(record->team); + } + return out; +} + +bool enterArena(Player& player, proto::SceneInfo& out) { + const data::PeakInfo* config = configOf(player); + if (config == nullptr) return false; + + PeakRun& run = player.peak(); + run.entryId = config->mapEntranceId; + run.mazeGroupId = config->mazeGroupId; + if (run.entryId == 0 || data::SceneRes::get().byEntry(run.entryId) == nullptr) { + logging::info("peak", "fight {}: arena {} is not in the scene dump, using {}", + config->id, run.entryId, kFallbackEntrance); + run.entryId = kFallbackEntrance; + run.mazeGroupId = kFallbackMazeGroup; + } + + // Every attempt starts at the entrance, not wherever the last fight ended. + SceneLocation before = player.location(); + player.location().floorId = 0; + ChallengeArena storage; + if (scene::load(player, run.entryId, 0, true, out, peak::arena(player, storage))) return true; + player.location() = before; + return false; +} + +} // namespace + +namespace peak { + +proto::ChallengePeakGroup group(const Player& player, uint32_t groupId) { + const data::Tables& tables = data::Tables::get(); + const PeakProgress& progress = player.peakProgress(); + + proto::ChallengePeakGroup out; + out.peak_group_id = groupId; + // Read the other way round by the client: set means the boss is on normal. + out.disable_hard_mode = progress.hardGroups.count(groupId) == 0; + + const data::PeakGroupInfo* info = tables.peakGroup(groupId); + if (info == nullptr) return out; + + uint32_t stars = 0; + proto::ChallengePeakBestRecordList best; + for (uint32_t mobId : info->mobIds) { + const data::PeakInfo* mob = tables.peak(mobId); + if (mob == nullptr) continue; + const PeakRecord* record = progress.record(mobId, false); + + proto::ChallengePeak entry; + entry.peak_id = mobId; + entry.has_passed = record != nullptr || cleared(); + entry.peak_avatar_id_list = teamOf(progress, mobId); + if (record != nullptr) { + entry.finished_target_list = record->targets; + entry.cycles_used = record->cycles; + } else if (cleared()) { + entry.finished_target_list = mob->targetIds; + } + stars += static_cast(entry.finished_target_list.size()); + + if (entry.has_passed) { + proto::ChallengePeakBestRecord clear; + clear.peak_id = mobId; + clear.cycles_used = entry.cycles_used; + clear.finished_target_list = entry.finished_target_list; + clear.peak_avatar_id_list = + recordAvatars(record != nullptr ? record->team : entry.peak_avatar_id_list); + best.peaks.push_back(std::move(clear)); + } + out.peaks.push_back(std::move(entry)); + } + out.count_of_peaks = static_cast(out.peaks.size()); + // A full clear is three stars a knight, whatever the records say. + out.obtained_stars = cleared() ? std::max(stars, out.count_of_peaks * 3) : stars; + if (!best.peaks.empty()) out.best_record_list = std::move(best); + if (cleared()) { + // Reported as taken: there is no inventory to pay them into. + if (const std::vector* rewards = tables.peakRewards(info->rewardGroupId)) { + out.taken_star_rewards = *rewards; + } + } + + const data::PeakInfo* boss = tables.peak(info->bossId); + if (boss == nullptr) return out; + const PeakRecord* easy = progress.record(boss->id, false); + auto& bossInfo = out.peak_boss.emplace(); + bossInfo.hard_mode_has_passed = progress.record(boss->id, true) != nullptr || cleared(); + bossInfo.easy_mode = clearance(progress, *boss, false); + bossInfo.hard_mode = clearance(progress, *boss, true); + if (easy != nullptr) { + bossInfo.finished_target_list = easy->targets; + } else if (cleared()) { + bossInfo.finished_target_list = boss->targetIds; + if (boss->hardTarget != 0) bossInfo.finished_target_list.push_back(boss->hardTarget); + } + return out; +} + +proto::GetChallengePeakDataScRsp overview(const Player& player) { + proto::GetChallengePeakDataScRsp rsp; + rsp.retcode = 0; + for (const data::PeakGroupInfo& info : data::Tables::get().peakGroups()) { + rsp.challenge_peak_groups.push_back(group(player, info.id)); + // The newest season is the one on show. + rsp.current_peak_group_id = info.id; + } + return rsp; +} + +void pushGroup(net::Session& session, const Player& player, uint32_t groupId) { + proto::ChallengePeakGroupDataUpdateScNotify notify; + notify.challenge_peak_group = group(player, groupId); + session.send(cmd::ChallengePeakGroupDataUpdateScNotify, notify); +} + +uint32_t start(Player& player, uint32_t peakId, uint32_t buffId, std::vector team, + proto::SceneInfo& out) { + const data::PeakInfo* config = data::Tables::get().peak(peakId); + if (config == nullptr || config->monsters.empty()) { + return fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + } + + PeakProgress& progress = player.peakProgress(); + if (team.empty()) team = teamOf(progress, peakId); + if (team.empty()) return fail(proto::Retcode::RET_CHALLENGE_LINEUP_EMPTY); + if (config->boss && buffId == 0) buffId = bossBuffOf(progress, *config); + progress.teams[peakId] = team; + if (config->boss) progress.bossBuffs[peakId] = buffId; + + PeakRun run; + run.active = true; + run.peakId = peakId; + run.groupId = config->groupId; + run.hard = config->boss && progress.hardGroups.count(config->groupId) != 0; + run.buffId = config->boss ? buffId : 0; + run.party = std::move(team); + // A retry keeps the spot the first attempt left from. + const PeakRun& previous = player.peak(); + run.origin = previous.active ? previous.origin : player.location(); + run.originPos = previous.active ? previous.originPos : player.position(); + + PeakRun saved = player.peak(); + player.peak() = std::move(run); + if (!enterArena(player, out)) { + player.peak() = std::move(saved); + return fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + } + + logging::info("peak", "fight {} of season {} started{}, stage {}", peakId, config->groupId, + player.peak().hard ? " on hard" : "", + player.peak().hard && config->hardEventId != 0 ? config->hardEventId + : config->eventId); + return 0; +} + +proto::LineupInfo lineup(const Player& player) { + return challenge::extraLineup(player, player.peak().party, + proto::ExtraLineupType::ExtraLineupType_LineupChallenge); +} + +const ChallengeArena* arena(const Player& player, ChallengeArena& storage) { + const data::PeakInfo* config = configOf(player); + if (config == nullptr) return nullptr; + const PeakRun& run = player.peak(); + storage.mazeGroupId = run.mazeGroupId; + storage.monsters = run.hard && !config->hardMonsters.empty() ? &config->hardMonsters + : &config->monsters; + storage.party = &run.party; + return &storage; +} + +void prepareBattle(const Player& player, BattleRequest& request) { + const data::PeakInfo* config = configOf(player); + if (config == nullptr) return; + const PeakRun& run = player.peak(); + + request.party = run.party; + // The enemy tags are not sent back by the client; they come from the fight's config. + request.floorBuffIds = run.hard ? config->hardTagBuffs : config->tagBuffs; + if (run.buffId != 0) request.floorBuffIds.push_back(run.buffId); + request.battleType = "AA"; + request.battleTargetIds = targetsOf(*config, run.hard); +} + +bool battleFinished(net::Session& session, Player& player, + const proto::PVEBattleResultCsReq& req) { + const data::PeakInfo* config = configOf(player); + if (config == nullptr) return false; + const PeakRun& run = player.peak(); + + // Backing out of the fight is not losing it; the client stays in the arena. + if (req.end_status == proto::BattleEndStatus::BATTLE_END_QUIT) return true; + + proto::ChallengePeakSettleScNotify notify; + notify.peak_id = run.peakId; + notify.is_win = req.end_status == proto::BattleEndStatus::BATTLE_END_WIN; + if (!notify.is_win) { + session.send(cmd::ChallengePeakSettleScNotify, notify); + logging::info("peak", "fight {} lost", run.peakId); + return true; + } + + uint32_t cycles = req.stt ? req.stt->round_cnt : 0; + uint32_t deaths = 0; + const proto::BattleTargetList* reported = nullptr; + if (req.stt) { + for (const proto::AvatarBattleInfo& avatar : req.stt->battle_avatar_list) { + if (avatar.avatar_status && avatar.avatar_status->left_hp == 0) ++deaths; + } + auto it = req.stt->battle_target_info.find(kTargetSlot); + if (it != req.stt->battle_target_info.end()) reported = &it->second; + } + + // The client's own count wins; without one, judge by cycles used and avatars lost. + std::vector targets = targetsOf(*config, run.hard); + std::vector finished; + for (uint32_t id : targets) { + const data::BattleTargetInfo* target = data::Tables::get().battleTarget(id); + if (target == nullptr) continue; + uint32_t progress = target->countsDeaths ? deaths : cycles; + if (reported != nullptr) { + for (const proto::BattleTarget& entry : reported->battle_target_list) { + if (entry.id == id) progress = entry.progress; + } + } + if (progress <= target->param) finished.push_back(id); + } + + PeakProgress& progress = player.peakProgress(); + notify.cycles_used = cycles; + notify.is_first_pass = progress.record(run.peakId, run.hard) == nullptr; + if (config->boss && run.hard) { + // A hard clear has a result screen of its own, with no targets on it. + notify.hard_mode_has_passed = true; + } else { + notify.finished_target_list = finished; + if (config->boss) { + uint32_t budget = turnBudget(config->targetIds); + notify.turn_left = budget > cycles ? budget - cycles : 0; + } + } + session.send(cmd::ChallengePeakSettleScNotify, notify); + + // Keep the better clear: more targets, then fewer cycles. + uint32_t key = PeakProgress::key(run.peakId, run.hard); + auto it = progress.records.find(key); + if (it == progress.records.end() || finished.size() > it->second.targets.size() || + (finished.size() == it->second.targets.size() && cycles < it->second.cycles)) { + progress.records[key] = PeakRecord{cycles, finished, run.party, run.buffId}; + } + pushGroup(session, player, run.groupId); + + logging::info("peak", "fight {} cleared{} in {} cycle(s), {} of {} target(s)", run.peakId, + run.hard ? " on hard" : "", cycles, finished.size(), targets.size()); + return true; +} + +void leave(net::Session& session, Player& player) { + PeakRun& run = player.peak(); + if (!run.active) return; + + uint32_t entryId = run.origin.entryId; + Position at = run.originPos; + run = PeakRun{}; + challenge::putBack(session, player, entryId, at); +} + +} // namespace peak +} // namespace game diff --git a/src/game/peak.h b/src/game/peak.h new file mode 100644 index 0000000..9863052 --- /dev/null +++ b/src/game/peak.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "game/scene.h" +#include "proto/gen/protos.h" + +namespace net { +class Session; +} + +namespace game { + +class Player; +struct BattleRequest; + +// Anomaly Arbitration: three knights and a boss a season, each one fight with its own +// team. Hard mode is a per-season toggle set before the boss is started; it swaps in +// the boss's hard stage, tags and target. +namespace peak { + +// Every season, cleared when gameplay.unlock_all_challenges is set. +proto::GetChallengePeakDataScRsp overview(const Player& player); +// One season as the overview draws it. +proto::ChallengePeakGroup group(const Player& player, uint32_t groupId); +// Re-sends one season, after anything that changes what the overview shows. +void pushGroup(net::Session& session, const Player& player, uint32_t groupId); + +// Starts a fight and enters its arena. Returns the retcode; on success `out` holds the +// arena. An empty team or a zero buff reuses the last ones picked for that fight. +uint32_t start(Player& player, uint32_t peakId, uint32_t buffId, std::vector team, + proto::SceneInfo& out); + +// The run's team, as the extra lineup the arena is fought with. +proto::LineupInfo lineup(const Player& player); + +// Same as challenge::arena, for a fight in progress. +const ChallengeArena* arena(const Player& player, ChallengeArena& storage); + +// What a fight inside the run brings: its team, the enemy tags, the boss buff, targets. +void prepareBattle(const Player& player, BattleRequest& request); + +// The fight has ended. Returns false when no run is going. +bool battleFinished(net::Session& session, Player& player, + const proto::PVEBattleResultCsReq& req); + +// Ends the run and puts the player back where they started. +void leave(net::Session& session, Player& player); + +} // namespace peak +} // namespace game diff --git a/src/game/peak_handlers.cpp b/src/game/peak_handlers.cpp new file mode 100644 index 0000000..8dea7ac --- /dev/null +++ b/src/game/peak_handlers.cpp @@ -0,0 +1,193 @@ +// Anomaly Arbitration. +// +// The overview is one GetChallengePeakData: every season, its three knights and its +// boss, with the last team used on each. Starting a fight answers with a bare retcode -- +// the arena has to go out first, as EnterSceneByServerScNotify, or the client is left +// on a black screen. The result is a settle notify off the battle result, and a season +// is sent again after anything that changes it. +#include +#include + +#include "core/logger.h" +#include "game/handlers.h" +#include "game/peak.h" +#include "game/player.h" +#include "net/cmd_ids.h" +#include "net/handler.h" +#include "net/session.h" +#include "proto/gen/protos.h" + +namespace game { +namespace { + +Player* playerOf(net::Session& session, const char* what) { + Player* player = session.player(); + if (player == nullptr) logging::warn("game", "{} before login", what); + return player; +} + +std::vector nonZero(const std::vector& ids) { + std::vector out; + for (uint32_t id : ids) { + if (id != 0) out.push_back(id); + } + return out; +} + +// The arena, then the team fighting in it, then the season with that team in its slot. +void pushArena(net::Session& session, Player& player, proto::SceneInfo& scene) { + proto::SyncLineupNotify sync; + sync.lineup = peak::lineup(player); + session.send(cmd::SyncLineupNotify, sync); + + proto::EnterSceneByServerScNotify notify; + notify.scene = std::move(scene); + notify.lineup = peak::lineup(player); + session.send(cmd::EnterSceneByServerScNotify, notify); + + peak::pushGroup(session, player, player.peak().groupId); +} + +void onGetChallengePeakData(net::Session& session, const proto::GetChallengePeakDataCsReq&) { + Player* player = playerOf(session, "GetChallengePeakData"); + if (player == nullptr) return; + session.send(cmd::GetChallengePeakDataScRsp, peak::overview(*player)); +} + +void onGetCurChallengePeak(net::Session& session, const proto::GetCurChallengePeakCsReq&) { + Player* player = playerOf(session, "GetCurChallengePeak"); + if (player == nullptr) return; + + proto::GetCurChallengePeakScRsp rsp; + rsp.retcode = 0; + const PeakRun& run = player->peak(); + if (run.active) { + rsp.peak_id = run.peakId; + rsp.boss_buff_id = run.buffId; + rsp.has_passed = true; + } + session.send(cmd::GetCurChallengePeakScRsp, rsp); +} + +void onSetChallengePeakMobLineupAvatar(net::Session& session, + const proto::SetChallengePeakMobLineupAvatarCsReq& req) { + Player* player = playerOf(session, "SetChallengePeakMobLineupAvatar"); + if (player == nullptr) return; + + PeakProgress& progress = player->peakProgress(); + for (const proto::ChallengePeakLineup& lineup : req.lineup_list) { + if (lineup.peak_id != 0) progress.teams[lineup.peak_id] = nonZero(lineup.peak_avatar_id_list); + } + player->saveNow(); + peak::pushGroup(session, *player, req.peak_group_id); + + proto::SetChallengePeakMobLineupAvatarScRsp rsp; + rsp.retcode = 0; + session.send(cmd::SetChallengePeakMobLineupAvatarScRsp, rsp); +} + +void onSetChallengePeakBossHardMode(net::Session& session, + const proto::SetChallengePeakBossHardModeCsReq& req) { + Player* player = playerOf(session, "SetChallengePeakBossHardMode"); + if (player == nullptr) return; + + // Kept per season and across restarts: the toggle is set before the boss starts. + std::set& hard = player->peakProgress().hardGroups; + if (req.is_hard_mode) { + hard.insert(req.peak_group_id); + } else { + hard.erase(req.peak_group_id); + } + player->saveNow(); + + proto::SetChallengePeakBossHardModeScRsp rsp; + rsp.retcode = 0; + rsp.peak_group_id = req.peak_group_id; + rsp.is_hard_mode = req.is_hard_mode; + session.send(cmd::SetChallengePeakBossHardModeScRsp, rsp); +} + +void onStartChallengePeak(net::Session& session, const proto::StartChallengePeakCsReq& req) { + Player* player = playerOf(session, "StartChallengePeak"); + if (player == nullptr) return; + + proto::SceneInfo scene; + proto::StartChallengePeakScRsp rsp; + rsp.retcode = peak::start(*player, req.peak_id, req.boss_buff_id, + nonZero(req.peak_avatar_id_list), scene); + if (rsp.retcode == 0) pushArena(session, *player, scene); + session.send(cmd::StartChallengePeakScRsp, rsp); +} + +void onReStartChallengePeak(net::Session& session, const proto::ReStartChallengePeakCsReq&) { + Player* player = playerOf(session, "ReStartChallengePeak"); + if (player == nullptr) return; + + // The result screen's retry: the same fight, team and buff, from the entrance. + proto::ReStartChallengePeakScRsp rsp; + const PeakRun& run = player->peak(); + if (!run.active) { + rsp.retcode = fail(proto::Retcode::RET_CHALLENGE_NOT_DOING); + } else { + proto::SceneInfo scene; + rsp.retcode = peak::start(*player, run.peakId, run.buffId, run.party, scene); + if (rsp.retcode == 0) pushArena(session, *player, scene); + } + session.send(cmd::ReStartChallengePeakScRsp, rsp); +} + +void onLeaveChallengePeak(net::Session& session, const proto::LeaveChallengePeakCsReq&) { + Player* player = playerOf(session, "LeaveChallengePeak"); + if (player == nullptr) return; + + bool wasRunning = player->peak().active; + uint32_t groupId = player->peak().groupId; + peak::leave(session, *player); + + proto::LeaveChallengePeakScRsp rsp; + rsp.retcode = 0; + session.send(cmd::LeaveChallengePeakScRsp, rsp); + + // Back on the overview, which refills its team slots from the season. + if (wasRunning) peak::pushGroup(session, *player, groupId); +} + +void onConfirmChallengePeakSettle(net::Session& session, + const proto::ConfirmChallengePeakSettleCsReq& req) { + proto::ConfirmChallengePeakSettleScRsp rsp; + rsp.retcode = 0; + rsp.peak_id = req.peak_id; + session.send(cmd::ConfirmChallengePeakSettleScRsp, rsp); +} + +void onTakeChallengePeakReward(net::Session& session, + const proto::TakeChallengePeakRewardCsReq& req) { + // A cleared season already reports every reward as taken, but the request still has + // to complete or the screen hangs. + proto::TakeChallengePeakRewardScRsp rsp; + rsp.retcode = 0; + rsp.peak_group_id = req.peak_group_id; + session.send(cmd::TakeChallengePeakRewardScRsp, rsp); +} + +} // namespace + +void registerPeakHandlers() { + net::on(cmd::GetChallengePeakDataCsReq, + onGetChallengePeakData); + net::on(cmd::GetCurChallengePeakCsReq, onGetCurChallengePeak); + net::on(cmd::SetChallengePeakMobLineupAvatarCsReq, + onSetChallengePeakMobLineupAvatar); + net::on(cmd::SetChallengePeakBossHardModeCsReq, + onSetChallengePeakBossHardMode); + net::on(cmd::StartChallengePeakCsReq, onStartChallengePeak); + net::on(cmd::ReStartChallengePeakCsReq, + onReStartChallengePeak); + net::on(cmd::LeaveChallengePeakCsReq, onLeaveChallengePeak); + net::on(cmd::ConfirmChallengePeakSettleCsReq, + onConfirmChallengePeakSettle); + net::on(cmd::TakeChallengePeakRewardCsReq, + onTakeChallengePeakReward); +} + +} // namespace game diff --git a/src/game/player.h b/src/game/player.h index 84f0929..c1a9171 100644 --- a/src/game/player.h +++ b/src/game/player.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include @@ -47,6 +49,81 @@ struct ChallengeRun { const std::vector& curParty() const { return party[stage > 1 ? 1 : 0]; } }; +// An Anomaly Arbitration fight in progress: one team, one monster, no second node. +struct PeakRun { + bool active = false; + uint32_t peakId = 0; + uint32_t groupId = 0; + bool hard = false; + uint32_t buffId = 0; // the boss buff the player picked + std::vector party; + // The arena actually entered; a season newer than the scene dump borrows another. + uint32_t entryId = 0; + uint32_t mazeGroupId = 0; + SceneLocation origin; + Position originPos; +}; + +// The three-node run 4.6 replaced Memory of Chaos's two with. Each node brings its own +// team and its own buff, and the cycle pool is spent across all three. +struct TierceRun { + bool active = false; + uint32_t tierceId = 0; + uint32_t stage = 0; // 0..2 + // Replaying one cleared node rather than walking the whole floor. + bool singleStage = false; + bool passed = false; + uint32_t roundsLeft = 0; + uint32_t deaths[3] = {0, 0, 0}; + uint32_t cycles[3] = {0, 0, 0}; + uint32_t scores[3] = {0, 0, 0}; + // proto::BattleEndStatus per node, as the result list reports it. + uint32_t endStatus[3] = {0, 0, 0}; + uint32_t buffs[3] = {0, 0, 0}; + std::vector party[3]; + SceneLocation origin; + Position originPos; + + uint32_t totalScore() const { return scores[0] + scores[1] + scores[2]; } + const std::vector& curParty() const { return party[stage < 3 ? stage : 2]; } +}; + +// What the three-node history remembers between runs: the teams and buffs the player +// last set per floor, and how far they got. +struct TierceProgress { + std::vector party[3]; + uint32_t buffs[3] = {0, 0, 0}; + uint32_t scores[3] = {0, 0, 0}; + uint32_t cycles[3] = {0, 0, 0}; + uint32_t deaths[3] = {0, 0, 0}; + bool cleared[3] = {false, false, false}; + bool passed = false; + std::vector targets; +}; + +// A cleared Anomaly Arbitration fight, as the overview shows it. +struct PeakRecord { + uint32_t cycles = 0; + std::vector targets; + std::vector team; + uint32_t buffId = 0; +}; + +// What the Anomaly Arbitration overview remembers between fights and restarts: the +// last team and boss buff per fight, the seasons on hard, and the best clears. +struct PeakProgress { + std::map> teams; + std::map bossBuffs; + std::set hardGroups; + std::map records; // by key() + + static uint32_t key(uint32_t peakId, bool hard) { return peakId * 2 + (hard ? 1 : 0); } + const PeakRecord* record(uint32_t peakId, bool hard) const { + auto it = records.find(key(peakId, hard)); + return it == records.end() ? nullptr : &it->second; + } +}; + // One logged-in account: identity, party, position and the current fight. class Player { public: @@ -83,6 +160,14 @@ public: const BattleContext& battle() const { return battle_; } ChallengeRun& challenge() { return challenge_; } const ChallengeRun& challenge() const { return challenge_; } + PeakRun& peak() { return peak_; } + const PeakRun& peak() const { return peak_; } + TierceRun& tierce() { return tierce_; } + const TierceRun& tierce() const { return tierce_; } + std::map& tierceHistory() { return tierceHistory_; } + const std::map& tierceHistory() const { return tierceHistory_; } + PeakProgress& peakProgress() { return peakProgress_; } + const PeakProgress& peakProgress() const { return peakProgress_; } uint32_t mainCharacter() const { return mainCharacter_; } void setMainCharacter(uint32_t value) { mainCharacter_ = value; } @@ -120,6 +205,10 @@ private: SceneState sceneState_; BattleContext battle_; ChallengeRun challenge_; + PeakRun peak_; + PeakProgress peakProgress_; + TierceRun tierce_; + std::map tierceHistory_; uint32_t mainCharacter_ = 8008; uint32_t marchType_ = 1224; diff --git a/src/game/player_store.cpp b/src/game/player_store.cpp index a3f4f56..a4cb79e 100644 --- a/src/game/player_store.cpp +++ b/src/game/player_store.cpp @@ -27,6 +27,16 @@ bool boolOr(const json& j, const char* key, bool fallback) { return (it != j.end() && it->is_boolean()) ? it->get() : fallback; } +std::vector u32Array(const json& j, const char* key) { + std::vector out; + auto it = j.find(key); + if (it == j.end() || !it->is_array()) return out; + for (const json& entry : *it) { + if (entry.is_number()) out.push_back(entry.get()); + } + return out; +} + } // namespace bool loadPlayerState(Player& player) { @@ -104,6 +114,63 @@ bool loadPlayerState(Player& player) { } book.setCurIndex(u32(j, "cur_squad", book.curIndex())); book.setMp(u32(j, "mp", book.maxMp())); + + if (auto peak = j.find("peak"); peak != j.end() && peak->is_object()) { + PeakProgress& progress = player.peakProgress(); + auto ids = [](const json& entry, const char* key) { + std::vector out; + auto it = entry.find(key); + if (it == entry.end() || !it->is_array()) return out; + for (const json& id : *it) { + if (id.is_number()) out.push_back(id.get()); + } + return out; + }; + for (uint32_t groupId : ids(*peak, "hard")) progress.hardGroups.insert(groupId); + if (auto fights = peak->find("fights"); fights != peak->end() && fights->is_array()) { + for (const json& fight : *fights) { + uint32_t id = fight.is_object() ? u32(fight, "id", 0) : 0; + if (id == 0) continue; + progress.teams[id] = ids(fight, "team"); + if (uint32_t buff = u32(fight, "buff", 0); buff != 0) progress.bossBuffs[id] = buff; + } + } + if (auto records = peak->find("records"); records != peak->end() && records->is_array()) { + for (const json& entry : *records) { + uint32_t id = entry.is_object() ? u32(entry, "id", 0) : 0; + if (id == 0) continue; + PeakRecord& record = + progress.records[PeakProgress::key(id, boolOr(entry, "hard", false))]; + record.cycles = u32(entry, "cycles", 0); + record.targets = ids(entry, "targets"); + record.team = ids(entry, "team"); + record.buffId = u32(entry, "buff", 0); + } + } + } + + // The three-node floors: what the team editor last held, and how far each got. + if (auto tierce = j.find("tierce"); tierce != j.end() && tierce->is_array()) { + for (const json& entry : *tierce) { + uint32_t id = entry.is_object() ? u32(entry, "id", 0) : 0; + if (id == 0) continue; + TierceProgress& progress = player.tierceHistory()[id]; + progress.passed = boolOr(entry, "passed", false); + progress.targets = u32Array(entry, "targets"); + auto nodes = entry.find("nodes"); + if (nodes == entry.end() || !nodes->is_array()) continue; + for (size_t stage = 0; stage < nodes->size() && stage < 3; ++stage) { + const json& node = (*nodes)[stage]; + if (!node.is_object()) continue; + progress.party[stage] = u32Array(node, "team"); + progress.buffs[stage] = u32(node, "buff", 0); + progress.scores[stage] = u32(node, "score", 0); + progress.cycles[stage] = u32(node, "cycles", 0); + progress.deaths[stage] = u32(node, "deaths", 0); + progress.cleared[stage] = boolOr(node, "cleared", false); + } + } + } return true; } @@ -140,6 +207,43 @@ bool savePlayerState(const Player& player) { {"entry_id", player.location().entryId}}}, }; + const PeakProgress& progress = player.peakProgress(); + json fights = json::array(); + for (const auto& [id, team] : progress.teams) { + auto buff = progress.bossBuffs.find(id); + fights.push_back({{"id", id}, + {"team", team}, + {"buff", buff != progress.bossBuffs.end() ? buff->second : 0u}}); + } + json records = json::array(); + for (const auto& [key, record] : progress.records) { + records.push_back({{"id", key / 2}, + {"hard", key % 2 == 1}, + {"cycles", record.cycles}, + {"targets", record.targets}, + {"team", record.team}, + {"buff", record.buffId}}); + } + j["peak"] = {{"hard", progress.hardGroups}, {"fights", fights}, {"records", records}}; + + json tierce = json::array(); + for (const auto& [id, floor] : player.tierceHistory()) { + json nodes = json::array(); + for (uint32_t stage = 0; stage < 3; ++stage) { + nodes.push_back({{"team", floor.party[stage]}, + {"buff", floor.buffs[stage]}, + {"score", floor.scores[stage]}, + {"cycles", floor.cycles[stage]}, + {"deaths", floor.deaths[stage]}, + {"cleared", floor.cleared[stage]}}); + } + tierce.push_back({{"id", id}, + {"passed", floor.passed}, + {"targets", floor.targets}, + {"nodes", nodes}}); + } + j["tierce"] = tierce; + const std::string& path = core::Config::get().paths.playerFile; if (!util::writeFile(path, j.dump(2))) { logging::warn("player", "could not write {}", path); diff --git a/src/game/rescue.cpp b/src/game/rescue.cpp index 0bd3b02..ea812c5 100644 --- a/src/game/rescue.cpp +++ b/src/game/rescue.cpp @@ -2,8 +2,10 @@ #include "core/logger.h" #include "game/challenge.h" +#include "game/peak.h" #include "game/player.h" #include "game/scene.h" +#include "game/tierce.h" #include "net/cmd_ids.h" #include "net/session.h" #include "proto/gen/protos.h" @@ -37,10 +39,30 @@ std::string unstick(net::Session& session, uint32_t entryId) { logging::info("rescue", "uid {}: {}", player->uid(), did); return did; } + if (player->peak().active && entryId == 0) { + peak::leave(session, *player); + note("left the arbitration"); + logging::info("rescue", "uid {}: {}", player->uid(), did); + return did; + } + if (player->tierce().active && entryId == 0) { + tierce::leave(session, *player); + note("left the challenge floor"); + logging::info("rescue", "uid {}: {}", player->uid(), did); + return did; + } if (player->challenge().active) { player->challenge() = ChallengeRun{}; note("abandoned the challenge"); } + if (player->peak().active) { + player->peak() = PeakRun{}; + note("abandoned the arbitration"); + } + if (player->tierce().active) { + player->tierce() = TierceRun{}; + note("abandoned the challenge floor"); + } uint32_t target = entryId != 0 ? entryId : player->location().entryId; proto::SceneInfo scene; diff --git a/src/game/scene.cpp b/src/game/scene.cpp index 61fba8e..376050f 100644 --- a/src/game/scene.cpp +++ b/src/game/scene.cpp @@ -2,6 +2,7 @@ #include +#include "core/config.h" #include "core/logger.h" #include "data/excel.h" #include "data/scene_res.h" @@ -105,13 +106,18 @@ proto::LineupInfo lineupInfo(const Player& player) { } proto::SceneEntityGroupInfo actorGroup(Player& player, const Position& at) { + return actorGroup(player, at, player.lineups().curMembers()); +} + +proto::SceneEntityGroupInfo actorGroup(Player& player, const Position& at, + const std::vector& members) { proto::MotionInfo motion = toMotion(at); proto::SceneEntityGroupInfo group; group.group_id = 0; uint32_t slot = 0; - for (uint32_t avatarId : player.lineups().curMembers()) { + for (uint32_t avatarId : members) { SceneEntity entity; entity.entityId = slot + 1; entity.kind = EntityKind::Actor; @@ -146,7 +152,12 @@ bool load(Player& player, uint32_t entryId, uint32_t teleportId, bool commit, // entrance's own anchor, then any pad on the floor. Position spawn = player.position(); const data::ResTeleport* anchor = teleportId != 0 ? floor->teleport(teleportId) : nullptr; - if (anchor == nullptr && floor->floorId != player.location().floorId) { + if (teleportId != 0 && anchor == nullptr) { + logging::warn("scene", "entry {} has no teleport {}, landing on its entrance", entryId, + teleportId); + } + // A teleport that asked to go somewhere always moves the player, even on this floor. + if (anchor == nullptr && (teleportId != 0 || floor->floorId != player.location().floorId)) { const data::EntranceInfo* entrance = data::Tables::get().entrance(entryId); uint32_t anchorId = entrance != nullptr ? entrance->startAnchorId : 0; anchor = data::SceneRes::get().anchor(entryId, anchorId); @@ -172,7 +183,10 @@ bool load(Player& player, uint32_t entryId, uint32_t teleportId, bool commit, scene.game_mode_type = floor->planeType; scene.world_id = fixWorldId(floor->worldId); scene.lighten_section_list = floor->sections; - scene.leader_entity_id = player.lineups().leaderSlot(player.lineups().curIndex()) + 1; + // An arena team is led from its first slot. + bool arenaTeam = arena != nullptr && arena->party != nullptr; + scene.leader_entity_id = + arenaTeam ? 1 : player.lineups().leaderSlot(player.lineups().curIndex()) + 1; scene.scene_identifier.emplace().floor_id = floor->floorId; for (const auto& [name, value] : floor->savedValues) scene.floor_saved_data[name] = value; @@ -274,10 +288,13 @@ bool load(Player& player, uint32_t entryId, uint32_t teleportId, bool commit, } for (uint32_t chest : group.chests) scene.opened_chests_list.push_back(chest); + const core::GameplayConfig& gameplay = core::Config::get().gameplay; for (uint32_t mission : group.finishedMainMissions) { + if (gameplay.missionSkipped(mission)) continue; missions.finished_main_mission_id_list.push_back(mission); } for (uint32_t mission : group.finishedSubMissions) { + if (gameplay.missionSkipped(mission)) continue; proto::Mission entry; entry.id = mission; entry.status = proto::MissionStatus::MissionStatus_MissionFinish; @@ -292,7 +309,8 @@ bool load(Player& player, uint32_t entryId, uint32_t teleportId, bool commit, // The party is group 0; its entity ids are slot + 1, which is what // SceneCastSkill and leader_entity_id refer to. - scene.entity_group_list.push_back(actorGroup(player, spawn)); + scene.entity_group_list.push_back(arenaTeam ? actorGroup(player, spawn, *arena->party) + : actorGroup(player, spawn)); out = std::move(scene); return true; diff --git a/src/game/scene.h b/src/game/scene.h index 571f66b..5c25f65 100644 --- a/src/game/scene.h +++ b/src/game/scene.h @@ -12,17 +12,18 @@ class Player; // Positions are quantised to 1/1000 of a unit, the way the client sends them. struct Position { - int32_t x = -26968; - int32_t y = 78953; - int32_t z = 14457; - int32_t rotY = 11858; + int32_t x = 42548; + int32_t y = 3716; + int32_t z = -38422; + int32_t rotY = 325717; }; -// The Astral Express parlour: somewhere every account can stand. +// Jarilo-VI's Outlying Snow Plains, on the space anchor beside a calyx, so a new +// account has a fight to hand. struct SceneLocation { - uint32_t planeId = 20411; - uint32_t floorId = 20411001; - uint32_t entryId = 2041101; + uint32_t planeId = 20101; + uint32_t floorId = 20101001; + uint32_t entryId = 2010101; }; enum class EntityKind : uint8_t { Actor, Npc, Monster, Prop }; @@ -79,6 +80,8 @@ proto::MotionInfo toMotion(const Position& position); struct ChallengeArena { uint32_t mazeGroupId = 0; const std::vector* monsters = nullptr; + // The team standing in it, which is not the squad the player walks around with. + const std::vector* party = nullptr; }; // Assembles SceneInfo out of the scene dump plus the current party. @@ -98,6 +101,8 @@ proto::SceneMapInfo mapInfo(const Player& player, uint32_t floorId); // The party actors as their own group, which is how a lineup edit is pushed. proto::SceneEntityGroupInfo actorGroup(Player& player, const Position& at); +proto::SceneEntityGroupInfo actorGroup(Player& player, const Position& at, + const std::vector& members); } // namespace scene } // namespace game diff --git a/src/game/scene_handlers.cpp b/src/game/scene_handlers.cpp index f69655d..5d84c7b 100644 --- a/src/game/scene_handlers.cpp +++ b/src/game/scene_handlers.cpp @@ -3,8 +3,10 @@ #include "data/scene_res.h" #include "game/challenge.h" #include "game/handlers.h" +#include "game/peak.h" #include "game/player.h" #include "game/scene.h" +#include "game/tierce.h" #include "net/cmd_ids.h" #include "net/handler.h" #include "net/session.h" @@ -32,6 +34,8 @@ void onGetCurSceneInfo(net::Session& session, const proto::GetCurSceneInfoCsReq& // every monster the dump has in it. ChallengeArena storage; const ChallengeArena* arena = challenge::arena(*player, storage); + if (arena == nullptr) arena = tierce::arena(*player, storage); + if (arena == nullptr) arena = peak::arena(*player, storage); if (scene::load(*player, player->location().entryId, 0, false, info, arena)) { rsp.scene = std::move(info); } else { @@ -49,6 +53,8 @@ void onGetCurSceneInfo(net::Session& session, const proto::GetCurSceneInfoCsReq& void onEnterScene(net::Session& session, const proto::EnterSceneCsReq& req) { Player* player = playerOf(session, "EnterScene"); if (player == nullptr) return; + logging::debug("scene", "enter {} teleport {} interact {} from entry {}", req.entry_id, + req.teleport_id, req.interact_id, player->location().entryId); proto::SceneInfo info; if (!scene::load(*player, req.entry_id, req.teleport_id, true, info)) { diff --git a/src/game/talk_handlers.cpp b/src/game/talk_handlers.cpp new file mode 100644 index 0000000..6868840 --- /dev/null +++ b/src/game/talk_handlers.cpp @@ -0,0 +1,79 @@ +// NPC talk state. +// +// Before it will offer a talk option the client asks whether the party has already met +// the npcs standing around it, and each of these replies has to carry back the id it +// was asked about -- an empty reply reads as "npc 0", so every npc stays unmet and the +// interact prompt comes up with nothing in it. Everyone is met; nobody has taken a +// talk reward. +#include "game/handlers.h" +#include "net/cmd_ids.h" +#include "net/handler.h" +#include "net/session.h" +#include "proto/gen/protos.h" + +namespace game { +namespace { + +void onGetFirstTalkNpc(net::Session& session, const proto::GetFirstTalkNpcCsReq& req) { + proto::GetFirstTalkNpcScRsp rsp; + rsp.retcode = 0; + for (uint32_t npcId : req.npc_id_list) { + proto::FirstNpcTalkInfo info; + info.npc_id = npcId; + info.is_meet = true; + rsp.npc_meet_status_list.push_back(info); + } + session.send(cmd::GetFirstTalkNpcScRsp, rsp); +} + +// The same question for the npcs a cutscene introduces. Left unmet, as the official +// server does -- claiming otherwise skips the first-meet performance. +void onGetFirstTalkByPerformanceNpc(net::Session& session, + const proto::GetFirstTalkByPerformanceNpcCsReq& req) { + proto::GetFirstTalkByPerformanceNpcScRsp rsp; + rsp.retcode = 0; + for (uint32_t performanceId : req.performance_id_list) { + proto::NpcMeetByPerformanceStatus status; + status.performance_id = performanceId; + rsp.npc_meet_status_list.push_back(status); + } + session.send(cmd::GetFirstTalkByPerformanceNpcScRsp, rsp); +} + +void onFinishFirstTalkNpc(net::Session& session, const proto::FinishFirstTalkNpcCsReq& req) { + proto::FinishFirstTalkNpcScRsp rsp; + rsp.retcode = 0; + rsp.npc_id = req.npc_id; + session.send(cmd::FinishFirstTalkNpcScRsp, rsp); +} + +void onFinishFirstTalkByPerformanceNpc(net::Session& session, + const proto::FinishFirstTalkByPerformanceNpcCsReq& req) { + proto::FinishFirstTalkByPerformanceNpcScRsp rsp; + rsp.retcode = 0; + rsp.performance_id = req.performance_id; + // Present but empty: there is no inventory to pay a first-meet reward into. + rsp.reward.emplace(); + session.send(cmd::FinishFirstTalkByPerformanceNpcScRsp, rsp); +} + +void onGetNpcTakenReward(net::Session& session, const proto::GetNpcTakenRewardCsReq& req) { + proto::GetNpcTakenRewardScRsp rsp; + rsp.retcode = 0; + rsp.npc_id = req.npc_id; + session.send(cmd::GetNpcTakenRewardScRsp, rsp); +} + +} // namespace + +void registerTalkHandlers() { + net::on(cmd::GetFirstTalkNpcCsReq, onGetFirstTalkNpc); + net::on(cmd::GetFirstTalkByPerformanceNpcCsReq, + onGetFirstTalkByPerformanceNpc); + net::on(cmd::FinishFirstTalkNpcCsReq, onFinishFirstTalkNpc); + net::on(cmd::FinishFirstTalkByPerformanceNpcCsReq, + onFinishFirstTalkByPerformanceNpc); + net::on(cmd::GetNpcTakenRewardCsReq, onGetNpcTakenReward); +} + +} // namespace game diff --git a/src/game/tierce.cpp b/src/game/tierce.cpp new file mode 100644 index 0000000..3682c7a --- /dev/null +++ b/src/game/tierce.cpp @@ -0,0 +1,506 @@ +#include "game/tierce.h" + +#include +#include + +#include "core/logger.h" +#include "data/excel.h" +#include "data/scene_res.h" +#include "game/battle.h" +#include "game/challenge.h" +#include "game/handlers.h" +#include "game/player.h" +#include "net/cmd_ids.h" +#include "net/session.h" + +namespace game { +namespace { + +constexpr uint32_t kNodes = 3; + +const data::ChallengeTierceInfo* configOf(const Player& player) { + const TierceRun& run = player.tierce(); + if (!run.active) return nullptr; + return data::Tables::get().challengeTierce(run.tierceId); +} + +// The floor a tierce row extends; everything but the third node comes from it. +const data::ChallengeInfo* floorOf(const data::ChallengeTierceInfo& config) { + return data::Tables::get().challenge(config.preChallengeId); +} + +const data::ChallengeInfo* floorOf(const Player& player) { + const data::ChallengeTierceInfo* config = configOf(player); + return config == nullptr ? nullptr : floorOf(*config); +} + +proto::ExtraLineupType lineupTypeOf(uint32_t stage) { + switch (stage) { + case 0: + return proto::ExtraLineupType::ExtraLineupType_LineupChallenge; + case 1: + return proto::ExtraLineupType::ExtraLineupType_LineupChallenge2; + default: + return proto::ExtraLineupType::ExtraLineupType_LineupChallenge3; + } +} + +uint32_t entranceOf(const data::ChallengeTierceInfo& config, const data::ChallengeInfo& floor, + uint32_t stage) { + if (stage >= 2) return config.mapEntranceId; + if (stage == 1 && floor.mapEntranceId2 != 0) return floor.mapEntranceId2; + return floor.mapEntranceId; +} + +// Memory of Chaos spends one pool of cycles across all three nodes; the other two keep +// the floor's own per-node limit. +bool poolsCycles(const data::ChallengeInfo& floor) { + return floor.kind == data::ChallengeKind::Memory; +} + +std::string battleTypeOf(const data::ChallengeInfo& floor) { + switch (floor.kind) { + case data::ChallengeKind::Story: + return "PF"; + case data::ChallengeKind::Boss: + return "AS"; + case data::ChallengeKind::Memory: + break; + } + return {}; +} + +// Which of the floor's star targets the run has met, as a flat list of target ids -- +// this mode reports the targets themselves rather than a bitmask. +std::vector finishedTargets(const Player& player) { + const data::ChallengeTierceInfo* config = configOf(player); + if (config == nullptr) return {}; + const TierceRun& run = player.tierce(); + const data::Tables& tables = data::Tables::get(); + + std::vector met; + for (uint32_t id : config->targetIds) { + const data::ChallengeTarget* target = tables.challengeTarget(id); + if (target == nullptr) continue; + bool ok = false; + switch (target->kind) { + case data::ChallengeTarget::Kind::RoundsLeft: + ok = run.roundsLeft >= target->param; + break; + case data::ChallengeTarget::Kind::DeadAvatar: + ok = run.deaths[0] + run.deaths[1] + run.deaths[2] == 0; + break; + case data::ChallengeTarget::Kind::TotalScore: + ok = run.totalScore() >= target->param; + break; + } + if (ok) met.push_back(id); + } + return met; +} + +bool enterArena(Player& player, proto::SceneInfo& out) { + const data::ChallengeTierceInfo* config = configOf(player); + if (config == nullptr) return false; + const data::ChallengeInfo* floor = floorOf(*config); + if (floor == nullptr) return false; + + uint32_t entryId = entranceOf(*config, *floor, player.tierce().stage); + ChallengeArena storage; + // Every node starts at its entrance, not wherever the last one ended. + SceneLocation before = player.location(); + player.location().floorId = 0; + if (scene::load(player, entryId, 0, true, out, tierce::arena(player, storage))) return true; + player.location() = before; + logging::warn("tierce", "floor {} node {} names entrance {}, which is not in the dump", + config->id, player.tierce().stage + 1, entryId); + return false; +} + +proto::ChallengeTierceStageData stageData(const TierceRun& run, uint32_t stage) { + proto::ChallengeTierceStageData data; + data.stage_index = stage; + data.end_status = static_cast(run.endStatus[stage]); + data.score_id = run.scores[stage]; + data.INLLMKEDGLC = run.cycles[stage]; + data.IBECKONIICF = run.deaths[stage]; + return data; +} + +// One node cleared, or the whole floor. The client redraws the node list off these. +void syncStage(net::Session& session, const Player& player, uint32_t stage) { + const TierceRun& run = player.tierce(); + proto::ChallengeTierceSyncNotify notify; + auto& single = notify.IANBCDJPDJF.emplace(); + single.stage_data = stageData(run, stage); + single.is_passed = run.passed; + single.finished_target_list = finishedTargets(player); + notify.MMCGBGDJIPN_case = proto::ChallengeTierceSyncNotify::k_IANBCDJPDJF; + session.send(cmd::ChallengeTierceSyncNotify, notify); +} + +void syncFloor(net::Session& session, const Player& player) { + const TierceRun& run = player.tierce(); + proto::ChallengeTierceSyncNotify notify; + auto& all = notify.DFNNKENBOEN.emplace(); + all.challenge_id = run.tierceId; + all.is_passed = run.passed; + all.finished_target_list = finishedTargets(player); + for (uint32_t stage = 0; stage < kNodes; ++stage) { + all.GBMBAHHDONN.push_back(stageData(run, stage)); + } + auto& record = all.EGEHLOPFELK.emplace(); + record.used_cycle_count = run.cycles[0] + run.cycles[1] + run.cycles[2]; + record.total_score = run.totalScore(); + notify.MMCGBGDJIPN_case = proto::ChallengeTierceSyncNotify::k_DFNNKENBOEN; + session.send(cmd::ChallengeTierceSyncNotify, notify); +} + +void syncMedal(net::Session& session, uint32_t groupId) { + proto::ChallengeTierceSyncNotify notify; + notify.medal.emplace().group_id = groupId; + notify.MMCGBGDJIPN_case = proto::ChallengeTierceSyncNotify::k_medal; + session.send(cmd::ChallengeTierceSyncNotify, notify); +} + +// Folds the run into what the floor's history shows from now on. +void record(Player& player) { + const TierceRun& run = player.tierce(); + TierceProgress& progress = player.tierceHistory()[run.tierceId]; + for (uint32_t stage = 0; stage < kNodes; ++stage) { + if (!run.party[stage].empty()) progress.party[stage] = run.party[stage]; + progress.buffs[stage] = run.buffs[stage]; + if (run.endStatus[stage] == + static_cast(proto::BattleEndStatus::BATTLE_END_WIN)) { + progress.cleared[stage] = true; + progress.scores[stage] = run.scores[stage]; + progress.cycles[stage] = run.cycles[stage]; + progress.deaths[stage] = run.deaths[stage]; + } + } + if (run.passed) progress.passed = true; + std::vector met = finishedTargets(player); + for (uint32_t id : met) { + if (std::find(progress.targets.begin(), progress.targets.end(), id) == + progress.targets.end()) { + progress.targets.push_back(id); + } + } +} + +// The per-node teams a start request carries, or the ones the floor remembers when it +// sends none. +void adoptLineups(Player& player, uint32_t tierceId, + const std::vector& stages) { + TierceRun& run = player.tierce(); + auto it = player.tierceHistory().find(tierceId); + const TierceProgress* progress = + it == player.tierceHistory().end() ? nullptr : &it->second; + + for (uint32_t stage = 0; stage < kNodes; ++stage) { + std::vector party; + if (stage < stages.size()) { + for (const proto::AvatarIdentifier& entry : stages[stage].lineup) { + if (entry.id != 0) party.push_back(entry.id); + } + run.buffs[stage] = stages[stage].buff_id; + } + if (party.empty() && progress != nullptr) party = progress->party[stage]; + if (stage >= stages.size() && progress != nullptr) run.buffs[stage] = progress->buffs[stage]; + run.party[stage] = std::move(party); + } +} + +} // namespace + +namespace tierce { + +proto::GetChallengeTierceDataScRsp history(const Player& player) { + const data::Tables& tables = data::Tables::get(); + + proto::GetChallengeTierceDataScRsp rsp; + rsp.retcode = 0; + for (const data::ChallengeInfo& floor : tables.challenges()) { + const data::ChallengeTierceInfo* config = tables.challengeTierceFor(floor.id); + if (config == nullptr) continue; + + auto it = player.tierceHistory().find(config->id); + const TierceProgress* progress = + it == player.tierceHistory().end() ? nullptr : &it->second; + + proto::ChallengeTierceData data; + data.challenge_id = config->id; + // Unlocking floors is GetChallenge's job; this only says what has been played. + data.is_passed = progress != nullptr && progress->passed; + if (progress != nullptr) data.finished_target_list = progress->targets; + auto& summary = data.record.emplace(); + for (uint32_t stage = 0; stage < kNodes; ++stage) { + proto::ChallengeTierceStageInfo info; + info.stage_index = stage; + if (progress != nullptr) { + info.buff_id = progress->buffs[stage]; + for (uint32_t avatarId : progress->party[stage]) { + proto::AvatarIdentifier entry; + entry.id = avatarId; + info.lineup.push_back(entry); + } + summary.used_cycle_count += progress->cycles[stage]; + summary.total_score += progress->scores[stage]; + + proto::ChallengeTierceStageData result; + result.stage_index = stage; + result.end_status = progress->cleared[stage] + ? proto::BattleEndStatus::BATTLE_END_WIN + : proto::BattleEndStatus::BATTLE_END_NONE; + result.score_id = progress->scores[stage]; + result.INLLMKEDGLC = progress->cycles[stage]; + result.IBECKONIICF = progress->deaths[stage]; + data.result_list.push_back(std::move(result)); + } + data.stage_info_list.push_back(std::move(info)); + } + rsp.challenge_info_list.push_back(std::move(data)); + } + return rsp; +} + +proto::ChallengeTierceChallengeInfo current(const Player& player) { + const TierceRun& run = player.tierce(); + + proto::ChallengeTierceChallengeInfo info; + info.challenge_id = run.tierceId; + info.stage_index = run.stage; + info.is_single_stage = run.singleStage; + for (uint32_t stage = 0; stage < kNodes; ++stage) { + info.lineup_list.push_back(lineup(player, stage)); + } + return info; +} + +proto::LineupInfo lineup(const Player& player, uint32_t stage) { + uint32_t index = std::min(stage, kNodes - 1); + return challenge::extraLineup(player, player.tierce().party[index], lineupTypeOf(index)); +} + +void setLineups(Player& player, uint32_t tierceId, + const std::vector& stages) { + if (data::Tables::get().challengeTierce(tierceId) == nullptr) return; + TierceProgress& progress = player.tierceHistory()[tierceId]; + for (uint32_t stage = 0; stage < kNodes && stage < stages.size(); ++stage) { + std::vector party; + for (const proto::AvatarIdentifier& entry : stages[stage].lineup) { + if (entry.id != 0) party.push_back(entry.id); + } + if (!party.empty()) progress.party[stage] = std::move(party); + progress.buffs[stage] = stages[stage].buff_id; + } + player.save(); +} + +uint32_t start(Player& player, uint32_t tierceId, bool single, uint32_t stageIndex, + const std::vector& stages, + proto::SceneInfo& out) { + const data::ChallengeTierceInfo* config = data::Tables::get().challengeTierce(tierceId); + if (config == nullptr) return fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + const data::ChallengeInfo* floor = floorOf(*config); + if (floor == nullptr) return fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + if (stageIndex >= kNodes) return fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + + TierceRun saved = player.tierce(); + + TierceRun run; + run.active = true; + run.tierceId = tierceId; + run.singleStage = single; + run.stage = single ? stageIndex : 0; + run.roundsLeft = poolsCycles(*floor) ? config->roundLimit : floor->roundLimit; + // A retry keeps the spot the first attempt left from. + run.origin = saved.active ? saved.origin : player.location(); + run.originPos = saved.active ? saved.originPos : player.position(); + + player.tierce() = std::move(run); + adoptLineups(player, tierceId, stages); + if (player.tierce().curParty().empty()) { + player.tierce() = std::move(saved); + return fail(proto::Retcode::RET_CHALLENGE_LINEUP_EMPTY); + } + if (!enterArena(player, out)) { + player.tierce() = std::move(saved); + return fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + } + + // Both cannot be going at once, and the older packet's run is what the client would + // otherwise still think it is in. + player.challenge() = ChallengeRun{}; + record(player); + player.saveNow(); + + logging::info("tierce", "floor {} started at node {}{}, {} cycle(s)", tierceId, + player.tierce().stage + 1, single ? " on its own" : "", + player.tierce().roundsLeft); + return 0; +} + +const ChallengeArena* arena(const Player& player, ChallengeArena& storage) { + const data::ChallengeTierceInfo* config = configOf(player); + if (config == nullptr) return nullptr; + const data::ChallengeInfo* floor = floorOf(*config); + if (floor == nullptr) return nullptr; + + const TierceRun& run = player.tierce(); + if (run.stage >= 2) { + storage.mazeGroupId = config->mazeGroupId; + storage.monsters = &config->monsters; + } else { + storage.mazeGroupId = floor->stages[run.stage].mazeGroupId; + storage.monsters = &floor->stages[run.stage].monsters; + } + storage.party = &run.curParty(); + return &storage; +} + +void prepareBattle(const Player& player, BattleRequest& request) { + const data::ChallengeTierceInfo* config = configOf(player); + if (config == nullptr) return; + const data::ChallengeInfo* floor = floorOf(*config); + if (floor == nullptr) return; + const TierceRun& run = player.tierce(); + + request.party = run.curParty(); + request.mazeBuffId = floor->mazeBuffId; + request.stageBuffId = run.buffs[run.stage < kNodes ? run.stage : kNodes - 1]; + request.roundsLimit = run.roundsLeft; + request.scoreSoFar = run.totalScore(); + request.battleType = battleTypeOf(*floor); + request.battleTargetIds = floor->battleTargetIds; +} + +bool nextStage(net::Session& session, Player& player, proto::SceneInfo& out) { + TierceRun& run = player.tierce(); + if (!run.active || run.singleStage || run.stage + 1 >= kNodes) return false; + + ++run.stage; + if (run.party[run.stage].empty()) { + --run.stage; + return false; + } + if (!enterArena(player, out)) { + --run.stage; + return false; + } + + proto::ChallengeLineupNotify swap; + swap.extra_lineup_type = lineupTypeOf(run.stage); + session.send(cmd::ChallengeLineupNotify, swap); + + proto::SyncLineupNotify sync; + sync.lineup = lineup(player, run.stage); + session.send(cmd::SyncLineupNotify, sync); + player.saveNow(); + return true; +} + +bool restart(net::Session& session, Player& player, proto::SceneInfo& out) { + TierceRun& run = player.tierce(); + if (!run.active) return false; + + // The node is fought again from scratch; what it already banked is dropped. + run.endStatus[run.stage] = 0; + run.scores[run.stage] = 0; + const data::ChallengeTierceInfo* config = configOf(player); + const data::ChallengeInfo* floor = config == nullptr ? nullptr : floorOf(*config); + if (floor != nullptr && poolsCycles(*floor)) { + run.roundsLeft += run.cycles[run.stage]; + } + run.cycles[run.stage] = 0; + run.deaths[run.stage] = 0; + + if (!enterArena(player, out)) return false; + + proto::SyncLineupNotify sync; + sync.lineup = lineup(player, run.stage); + session.send(cmd::SyncLineupNotify, sync); + player.saveNow(); + return true; +} + +bool battleFinished(net::Session& session, Player& player, + const proto::PVEBattleResultCsReq& req) { + const data::ChallengeTierceInfo* config = configOf(player); + if (config == nullptr) return false; + const data::ChallengeInfo* floor = floorOf(*config); + if (floor == nullptr) return false; + + TierceRun& run = player.tierce(); + uint32_t stage = std::min(run.stage, kNodes - 1); + + // Backing out of a node is not losing the floor; the client stays put. + if (req.end_status == proto::BattleEndStatus::BATTLE_END_QUIT) return true; + + if (req.stt) { + // Pure Fiction and Apocalyptic Shadow report a running total, so this node's + // share is whatever is above what the earlier ones banked. + uint32_t total = req.stt->challenge_score; + uint32_t before = run.totalScore() - run.scores[stage]; + run.scores[stage] = total > before ? total - before : 0; + + for (const proto::AvatarBattleInfo& avatar : req.stt->battle_avatar_list) { + if (avatar.avatar_status && avatar.avatar_status->left_hp == 0) ++run.deaths[stage]; + } + run.cycles[stage] = std::max(run.cycles[stage], req.stt->round_cnt); + if (poolsCycles(*floor)) { + uint32_t used = run.cycles[0] + run.cycles[1] + run.cycles[2]; + uint32_t limit = config->roundLimit; + run.roundsLeft = limit > used ? limit - used : 0; + } + } + + bool win = req.end_status == proto::BattleEndStatus::BATTLE_END_WIN; + if (!win) { + run.endStatus[stage] = static_cast(proto::BattleEndStatus::BATTLE_END_LOSE); + syncFloor(session, player); + record(player); + player.saveNow(); + logging::info("tierce", "floor {} failed at node {}", run.tierceId, stage + 1); + return true; + } + + // Monsters still standing means this node is not over. + for (const SceneEntity& entity : player.sceneState().entities()) { + if (entity.kind == EntityKind::Monster) return true; + } + + run.endStatus[stage] = static_cast(proto::BattleEndStatus::BATTLE_END_WIN); + // Replaying one node ends there; walking the floor ends after the third, and only + // the third clears the floor either way. + bool last = run.singleStage || stage + 1 >= kNodes; + if (stage + 1 >= kNodes) run.passed = true; + record(player); + player.saveNow(); + + logging::info("tierce", "floor {} node {} cleared in {} cycle(s), score {}", run.tierceId, + stage + 1, run.cycles[stage], run.scores[stage]); + + if (last) { + syncFloor(session, player); + if (run.passed) syncMedal(session, floor->groupId); + } else { + // The client asks for the next node itself once it has read this. + syncStage(session, player, stage); + } + return true; +} + +void leave(net::Session& session, Player& player) { + TierceRun& run = player.tierce(); + if (!run.active) return; + + record(player); + uint32_t entryId = run.origin.entryId; + Position at = run.originPos; + run = TierceRun{}; + challenge::putBack(session, player, entryId, at); +} + +} // namespace tierce +} // namespace game diff --git a/src/game/tierce.h b/src/game/tierce.h new file mode 100644 index 0000000..4eedd0c --- /dev/null +++ b/src/game/tierce.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +#include "game/scene.h" +#include "proto/gen/protos.h" + +namespace net { +class Session; +} + +namespace game { + +class Player; +struct BattleRequest; + +// The three-node floor 4.6 replaced the two-node one with. A ChallengeMazeTierce row +// bolts a third node onto a Memory of Chaos, Pure Fiction or Apocalyptic Shadow floor, +// and from then on the client starts that whole floor through here rather than through +// StartChallenge -- so a floor with a tierce row never sees the older packet at all. +// +// The first two nodes are the ones the floor already had; the third brings its own +// arena and monster. Each node fights with its own team and its own picked buff. +namespace tierce { + +// Every floor that has a third node, with whatever the player has done on it. +proto::GetChallengeTierceDataScRsp history(const Player& player); +// The run in progress, as the client models it. Meaningless when none is. +proto::ChallengeTierceChallengeInfo current(const Player& player); +// The team fighting node `stage` (0..2), as an extra lineup rather than a squad. +proto::LineupInfo lineup(const Player& player, uint32_t stage); + +// Remembers what the team editor set without starting anything, so the per-node UI +// keeps showing it. +void setLineups(Player& player, uint32_t tierceId, + const std::vector& stages); + +// Starts a floor, entering the arena for its first node -- or for `stageIndex` alone +// when the player is replaying one. Returns the retcode; on success `out` holds the +// arena. +uint32_t start(Player& player, uint32_t tierceId, bool single, uint32_t stageIndex, + const std::vector& stages, + proto::SceneInfo& out); + +// Walks on to the next node; false when the run is on its last. +bool nextStage(net::Session& session, Player& player, proto::SceneInfo& out); +// Re-enters the node in progress, with its counters rolled back. +bool restart(net::Session& session, Player& player, proto::SceneInfo& out); + +// Same as challenge::arena, for the node in progress. +const ChallengeArena* arena(const Player& player, ChallengeArena& storage); + +// What the node brings to a fight: its team, the floor's maze buff, the buff the player +// picked for it, the cycles left and the mode's win condition. +void prepareBattle(const Player& player, BattleRequest& request); + +// A fight inside a run has ended. False when no run is going. +bool battleFinished(net::Session& session, Player& player, + const proto::PVEBattleResultCsReq& req); + +// Ends the run and puts the player back where they started. +void leave(net::Session& session, Player& player); + +} // namespace tierce +} // namespace game diff --git a/src/game/tierce_handlers.cpp b/src/game/tierce_handlers.cpp new file mode 100644 index 0000000..83ca5f3 --- /dev/null +++ b/src/game/tierce_handlers.cpp @@ -0,0 +1,169 @@ +// The three-node challenge floor. +// +// StartChallengeTierce answers with the run and the arena in one response -- unlike +// Anomaly Arbitration, this one carries its SceneInfo in the reply rather than in a +// notify, and the same goes for the next node and a restart. Clearing a node is +// reported with ChallengeTierceSyncNotify; the client then asks for the node after it. +#include + +#include "core/logger.h" +#include "game/handlers.h" +#include "game/player.h" +#include "game/tierce.h" +#include "net/cmd_ids.h" +#include "net/handler.h" +#include "net/session.h" +#include "proto/gen/protos.h" + +namespace game { +namespace { + +Player* playerOf(net::Session& session, const char* what) { + Player* player = session.player(); + if (player == nullptr) logging::warn("game", "{} before login", what); + return player; +} + +// The team for the node being entered, so the client swaps to it before the scene. +void pushLineup(net::Session& session, const Player& player) { + uint32_t stage = player.tierce().stage; + + proto::ChallengeLineupNotify swap; + swap.extra_lineup_type = stage >= 2 + ? proto::ExtraLineupType::ExtraLineupType_LineupChallenge3 + : stage == 1 + ? proto::ExtraLineupType::ExtraLineupType_LineupChallenge2 + : proto::ExtraLineupType::ExtraLineupType_LineupChallenge; + session.send(cmd::ChallengeLineupNotify, swap); + + proto::SyncLineupNotify sync; + sync.lineup = tierce::lineup(player, stage); + session.send(cmd::SyncLineupNotify, sync); +} + +void onGetChallengeTierceData(net::Session& session, const proto::GetChallengeTierceDataCsReq&) { + Player* player = playerOf(session, "GetChallengeTierceData"); + if (player == nullptr) return; + session.send(cmd::GetChallengeTierceDataScRsp, tierce::history(*player)); +} + +// Asked every login. Carries the run in progress so a reconnect lands back in it. +void onGetChallengeTierceController(net::Session& session, + const proto::GetChallengeTierceControllerCsReq&) { + Player* player = playerOf(session, "GetChallengeTierceController"); + if (player == nullptr) return; + + proto::GetChallengeTierceControllerScRsp rsp; + rsp.retcode = 0; + if (player->tierce().active) { + rsp.challenge_tierce_info = tierce::current(*player); + } + session.send(cmd::GetChallengeTierceControllerScRsp, rsp); +} + +void onSetChallengeTierceLineup(net::Session& session, + const proto::SetChallengeTierceLineupCsReq& req) { + Player* player = playerOf(session, "SetChallengeTierceLineup"); + if (player == nullptr) return; + + tierce::setLineups(*player, req.challenge_id, req.stage_info_list); + + proto::SetChallengeTierceLineupScRsp rsp; + rsp.retcode = 0; + session.send(cmd::SetChallengeTierceLineupScRsp, rsp); +} + +void onStartChallengeTierce(net::Session& session, const proto::StartChallengeTierceCsReq& req) { + Player* player = playerOf(session, "StartChallengeTierce"); + if (player == nullptr) return; + + proto::StartChallengeTierceScRsp rsp; + proto::SceneInfo scene; + rsp.retcode = tierce::start(*player, req.challenge_id, req.is_single_stage, req.stage_index, + req.stage_info_list, scene); + if (rsp.retcode != 0) { + session.send(cmd::StartChallengeTierceScRsp, rsp); + return; + } + + pushLineup(session, *player); + rsp.challenge_tierce_info = tierce::current(*player); + rsp.scene = std::move(scene); + session.send(cmd::StartChallengeTierceScRsp, rsp); +} + +void onStartNextChallengeTierce(net::Session& session, + const proto::StartNextChallengeTierceCsReq&) { + Player* player = playerOf(session, "StartNextChallengeTierce"); + if (player == nullptr) return; + + proto::StartNextChallengeTierceScRsp rsp; + proto::SceneInfo scene; + if (!tierce::nextStage(session, *player, scene)) { + rsp.retcode = fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + session.send(cmd::StartNextChallengeTierceScRsp, rsp); + return; + } + rsp.retcode = 0; + rsp.challenge_tierce_info = tierce::current(*player); + rsp.scene = std::move(scene); + session.send(cmd::StartNextChallengeTierceScRsp, rsp); +} + +void onRestartChallengeTierce(net::Session& session, const proto::RestartChallengeTierceCsReq&) { + Player* player = playerOf(session, "RestartChallengeTierce"); + if (player == nullptr) return; + + proto::RestartChallengeTierceScRsp rsp; + proto::SceneInfo scene; + if (!tierce::restart(session, *player, scene)) { + rsp.retcode = fail(proto::Retcode::RET_CHALLENGE_NOT_EXIST); + session.send(cmd::RestartChallengeTierceScRsp, rsp); + return; + } + rsp.retcode = 0; + rsp.GFACJIPNIAN = std::move(scene); + session.send(cmd::RestartChallengeTierceScRsp, rsp); +} + +// The settle screen closing. Nothing to hand over -- rewards are already taken. +void onConfirmChallengeTierceStageSettle( + net::Session& session, const proto::ConfirmChallengeTierceStageSettleCsReq&) { + proto::ConfirmChallengeTierceStageSettleScRsp rsp; + rsp.retcode = 0; + session.send(cmd::ConfirmChallengeTierceStageSettleScRsp, rsp); +} + +void onLeaveChallengeTierce(net::Session& session, const proto::LeaveChallengeTierceCsReq&) { + Player* player = playerOf(session, "LeaveChallengeTierce"); + if (player == nullptr) return; + + tierce::leave(session, *player); + + proto::LeaveChallengeTierceScRsp rsp; + rsp.retcode = 0; + session.send(cmd::LeaveChallengeTierceScRsp, rsp); +} + +} // namespace + +void registerTierceHandlers() { + net::on(cmd::GetChallengeTierceDataCsReq, + onGetChallengeTierceData); + net::on(cmd::GetChallengeTierceControllerCsReq, + onGetChallengeTierceController); + net::on(cmd::SetChallengeTierceLineupCsReq, + onSetChallengeTierceLineup); + net::on(cmd::StartChallengeTierceCsReq, + onStartChallengeTierce); + net::on(cmd::StartNextChallengeTierceCsReq, + onStartNextChallengeTierce); + net::on(cmd::RestartChallengeTierceCsReq, + onRestartChallengeTierce); + net::on( + cmd::ConfirmChallengeTierceStageSettleCsReq, onConfirmChallengeTierceStageSettle); + net::on(cmd::LeaveChallengeTierceCsReq, + onLeaveChallengeTierce); +} + +} // namespace game diff --git a/src/sdk/routes.cpp b/src/sdk/routes.cpp index 422fc5c..3c55bb9 100644 --- a/src/sdk/routes.cpp +++ b/src/sdk/routes.cpp @@ -1,5 +1,8 @@ #include "sdk/routes.h" +#include +#include + #include #include "core/config.h" @@ -14,6 +17,9 @@ using json = nlohmann::json; namespace sdk { namespace { +std::mutex versionMutex; +std::string lastVersion; + json accountBlock(const std::string& uid, const std::string& token) { return json{{"uid", uid}, {"name", ""}, @@ -101,6 +107,11 @@ std::string gatewayBody(const std::string& version) { } // namespace +std::string lastClientVersion() { + std::lock_guard lock(versionMutex); + return lastVersion; +} + void registerRoutes(http::Server& server) { server.get("/query_dispatch", [](const http::Request&, http::Response& res) { res.text(dispatchBody()); @@ -108,6 +119,10 @@ void registerRoutes(http::Server& server) { server.get("/query_gateway", [](const http::Request& req, http::Response& res) { std::string version = req.queryValue("version"); + { + std::lock_guard lock(versionMutex); + lastVersion = version; + } logging::info("dispatch", "query_gateway version={} seed={}", version, req.queryValue("dispatch_seed")); res.text(gatewayBody(version)); diff --git a/src/sdk/routes.h b/src/sdk/routes.h index 176ddf0..75277b9 100644 --- a/src/sdk/routes.h +++ b/src/sdk/routes.h @@ -6,4 +6,8 @@ namespace sdk { void registerRoutes(http::Server& server); +// The client version the last gateway query named, such as "CNBETAWin4.5.54"; empty +// until one has. The game connection that follows carries nothing that says. +std::string lastClientVersion(); + } // namespace sdk diff --git a/tests/test_flow.cpp b/tests/test_flow.cpp index 89e63ba..64e57a9 100644 --- a/tests/test_flow.cpp +++ b/tests/test_flow.cpp @@ -439,6 +439,35 @@ void runFlowTests() { } } + // Every npc the client asks about comes back as already met, under the id it asked + // about: an empty reply is what leaves an npc with no talk option. + { + proto::GetFirstTalkNpcCsReq talk; + talk.npc_id_list = {1001, 1002, 1003}; + client.send(cmd::GetFirstTalkNpcCsReq, talk); + check(client.await(cmd::GetFirstTalkNpcScRsp, packet), "the npc meet query answers"); + + proto::GetFirstTalkNpcScRsp talkRsp; + check(parseBody(packet, talkRsp), "the npc meet query parses"); + check(talkRsp.npc_meet_status_list.size() == 3, "one status per npc asked about"); + bool echoed = talkRsp.npc_meet_status_list.size() == 3; + for (size_t i = 0; i < talkRsp.npc_meet_status_list.size(); ++i) { + if (talkRsp.npc_meet_status_list[i].npc_id != talk.npc_id_list[i] || + !talkRsp.npc_meet_status_list[i].is_meet) { + echoed = false; + } + } + check(echoed, "each carries its own id and is already met"); + + proto::GetNpcTakenRewardCsReq reward; + reward.npc_id = 1003; + client.send(cmd::GetNpcTakenRewardCsReq, reward); + check(client.await(cmd::GetNpcTakenRewardScRsp, packet), "the npc reward query answers"); + proto::GetNpcTakenRewardScRsp rewardRsp; + check(parseBody(packet, rewardRsp), "the npc reward query parses"); + check(rewardRsp.npc_id == 1003, "about the npc that was asked for"); + } + // A calyx, the way the Survival Index starts one: no prop to walk up to, just a // cocoon id and how many runs were bought. { @@ -618,6 +647,282 @@ void runFlowTests() { check(!curRsp.cur_challenge.has(), "and there is no longer one going"); } + // The three-node floor: the packet 4.6 starts Memory of Chaos with. Unlike the older + // one it carries its arena in the reply rather than in a notify. + { + proto::StartChallengeTierceCsReq start; + start.challenge_id = 5213; + for (uint32_t stage = 0; stage < 3; ++stage) { + proto::ChallengeTierceStageLineupInfo info; + proto::AvatarIdentifier one; + one.id = 8001; + proto::AvatarIdentifier two; + two.id = 1001; + info.lineup.push_back(one); + info.lineup.push_back(two); + start.stage_info_list.push_back(std::move(info)); + } + client.send(cmd::StartChallengeTierceCsReq, start); + check(client.await(cmd::StartChallengeTierceScRsp, packet), "starting the floor answers"); + + proto::StartChallengeTierceScRsp startRsp; + check(parseBody(packet, startRsp), "the start parses"); + check(startRsp.retcode == 0, "and was accepted"); + check(startRsp.scene.has(), "with the arena in the reply itself"); + check(startRsp.challenge_tierce_info.has(), "and the run"); + if (startRsp.challenge_tierce_info) { + check(startRsp.challenge_tierce_info->challenge_id == 5213, "for the floor asked for"); + check(startRsp.challenge_tierce_info->stage_index == 0, "starting on its first node"); + check(startRsp.challenge_tierce_info->lineup_list.size() == 3, "with all three teams"); + } + + uint32_t nodeMonster = 0; + size_t nodeMonsters = 0; + if (startRsp.scene) { + check(startRsp.scene->entry_id == 3014002, "on the floor's own map entrance"); + for (const proto::SceneEntityGroupInfo& group : startRsp.scene->entity_group_list) { + for (const proto::SceneEntityInfo& entity : group.entity_list) { + if (!entity.npc_monster) continue; + ++nodeMonsters; + nodeMonster = entity.entity_id; + } + } + } + check(nodeMonsters == 1, "holding only the node's own monster"); + + if (nodeMonster != 0) { + proto::SceneCastSkillCsReq cast; + cast.cast_entity_id = 1; + cast.hit_target_entity_id_list.push_back(nodeMonster); + client.send(cmd::SceneCastSkillCsReq, cast); + check(client.await(cmd::SceneCastSkillScRsp, packet), "the node's fight answers"); + + proto::SceneCastSkillScRsp castRsp; + check(parseBody(packet, castRsp), "the node's fight parses"); + check(castRsp.battle_info.has(), "a fight was started"); + if (castRsp.battle_info) { + // The cycle pool is the tierce row's 45, not the floor's own 30. + check(castRsp.battle_info->rounds_limit == 45, "with the floor-wide cycle pool"); + bool mazeBuff = false; + for (const proto::BattleBuff& buff : castRsp.battle_info->buff_list) { + if (buff.id == 3030146) mazeBuff = true; + } + check(mazeBuff, "and the maze buff of the floor it extends"); + + proto::PVEBattleResultCsReq result; + result.battle_id = castRsp.battle_info->battle_id; + result.stage_id = castRsp.battle_info->stage_id; + result.end_status = proto::BattleEndStatus::BATTLE_END_WIN; + result.stt.emplace().round_cnt = 5; + client.send(cmd::PVEBattleResultCsReq, result); + check(client.await(cmd::PVEBattleResultScRsp, packet), "the result answers"); + + net::Packet syncPacket; + check(client.await(cmd::ChallengeTierceSyncNotify, syncPacket), + "clearing a node reports it"); + } + } + + client.sendEmpty(cmd::StartNextChallengeTierceCsReq); + check(client.await(cmd::StartNextChallengeTierceScRsp, packet), "the next node answers"); + proto::StartNextChallengeTierceScRsp nextRsp; + check(parseBody(packet, nextRsp), "the next node parses"); + check(nextRsp.retcode == 0, "and was accepted"); + check(nextRsp.scene.has(), "carrying its arena too"); + check(nextRsp.challenge_tierce_info.has() && + nextRsp.challenge_tierce_info->stage_index == 1, + "on the second node now"); + + client.sendEmpty(cmd::LeaveChallengeTierceCsReq); + check(client.await(cmd::LeaveChallengeTierceScRsp, packet), "leaving answers"); + check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back"); + + client.sendEmpty(cmd::GetChallengeTierceDataCsReq); + check(client.await(cmd::GetChallengeTierceDataScRsp, packet), "the history answers"); + proto::GetChallengeTierceDataScRsp dataRsp; + check(parseBody(packet, dataRsp), "the history parses"); + bool remembered = false; + for (const proto::ChallengeTierceData& entry : dataRsp.challenge_info_list) { + if (entry.challenge_id != 5213) continue; + remembered = true; + check(!entry.result_list.empty(), "with what the run did on it"); + } + check(remembered, "and the floor is in it"); + } + + // Anomaly Arbitration: a knight on its own arena -- team, start, fight, settle, retry, + // leave -- then season ten's boss on hard, whose arena is newer than the scene dump. + { + client.sendEmpty(cmd::GetChallengePeakDataCsReq); + check(client.await(cmd::GetChallengePeakDataScRsp, packet), "arbitration data answered"); + proto::GetChallengePeakDataScRsp peakData; + check(parseBody(packet, peakData), "arbitration data parses"); + check(peakData.challenge_peak_groups.size() >= 10, "with every season in it"); + check(peakData.current_peak_group_id == 10, "and the newest on show"); + + proto::SetChallengePeakMobLineupAvatarCsReq teams; + teams.peak_group_id = 9; + proto::ChallengePeakLineup knightTeam; + knightTeam.peak_id = 901; + knightTeam.peak_avatar_id_list = {8001, 1001}; + teams.lineup_list.push_back(knightTeam); + client.send(cmd::SetChallengePeakMobLineupAvatarCsReq, teams); + check(client.await(cmd::SetChallengePeakMobLineupAvatarScRsp, packet), + "setting a knight's team answers"); + net::Packet groupPacket; + check(client.await(cmd::ChallengePeakGroupDataUpdateScNotify, groupPacket), + "and re-sends the season"); + proto::ChallengePeakGroupDataUpdateScNotify groupUpdate; + check(parseBody(groupPacket, groupUpdate), "the season parses"); + bool teamKept = false; + if (groupUpdate.challenge_peak_group) { + for (const proto::ChallengePeak& peak : groupUpdate.challenge_peak_group->peaks) { + if (peak.peak_id == 901 && peak.peak_avatar_id_list.size() == 2) teamKept = true; + } + } + check(teamKept, "with the team in the knight's slot"); + + // Sent without a team, the start falls back on the one just set. + proto::StartChallengePeakCsReq start; + start.peak_id = 901; + client.send(cmd::StartChallengePeakCsReq, start); + check(client.await(cmd::StartChallengePeakScRsp, packet), "starting a knight answers"); + proto::StartChallengePeakScRsp startRsp; + check(parseBody(packet, startRsp), "the start parses"); + check(startRsp.retcode == 0, "and was accepted"); + + auto arenaOf = [&](const char* what, uint32_t& monster, size_t& monsters, size_t& actors, + uint32_t& entryId) { + net::Packet arenaPacket; + check(client.await(cmd::EnterSceneByServerScNotify, arenaPacket), what); + proto::EnterSceneByServerScNotify arena; + check(parseBody(arenaPacket, arena), "the arena parses"); + monster = 0; + monsters = actors = 0; + entryId = arena.scene ? arena.scene->entry_id : 0; + if (!arena.scene) return; + for (const proto::SceneEntityGroupInfo& group : arena.scene->entity_group_list) { + for (const proto::SceneEntityInfo& entity : group.entity_list) { + if (entity.actor) ++actors; + if (!entity.npc_monster) continue; + ++monsters; + monster = entity.entity_id; + } + } + }; + uint32_t knight = 0; + size_t monsters = 0; + size_t actors = 0; + uint32_t entryId = 0; + arenaOf("the arena went out before the response", knight, monsters, actors, entryId); + check(entryId == 3014501, "on the season's own arena"); + check(monsters == 1, "with the knight and nothing else in it"); + check(actors == 2, "and the fight's team standing in it, not the squad"); + + auto fight = [&](uint32_t monster, uint32_t rounds, proto::SceneBattleInfo& battle) { + proto::SceneCastSkillCsReq cast; + cast.cast_entity_id = 1; + cast.hit_target_entity_id_list.push_back(monster); + client.send(cmd::SceneCastSkillCsReq, cast); + check(client.await(cmd::SceneCastSkillScRsp, packet), "the fight answers"); + proto::SceneCastSkillScRsp castRsp; + check(parseBody(packet, castRsp), "the fight parses"); + check(castRsp.battle_info.has(), "a fight was started"); + if (!castRsp.battle_info) return false; + battle = *castRsp.battle_info; + + proto::PVEBattleResultCsReq result; + result.battle_id = battle.battle_id; + result.stage_id = battle.stage_id; + result.end_status = proto::BattleEndStatus::BATTLE_END_WIN; + result.stt.emplace().round_cnt = rounds; + client.send(cmd::PVEBattleResultCsReq, result); + check(client.await(cmd::PVEBattleResultScRsp, packet), "the result answers"); + return true; + }; + auto targetsIn = [](const proto::SceneBattleInfo& battle) { + auto slot = battle.battle_target_info.find(5); + return slot == battle.battle_target_info.end() ? size_t{0} + : slot->second.battle_target_list.size(); + }; + + proto::SceneBattleInfo battle; + if (knight != 0 && fight(knight, 3, battle)) { + check(battle.stage_id == 30509011, "against the knight's own stage"); + check(battle.battle_avatar_list.size() == 2, "with the knight's team"); + check(targetsIn(battle) == 3, "and its three targets"); + + net::Packet settlePacket; + check(client.await(cmd::ChallengePeakSettleScNotify, settlePacket), + "winning settles the knight"); + proto::ChallengePeakSettleScNotify settle; + check(parseBody(settlePacket, settle), "the settle parses"); + check(settle.is_win && settle.peak_id == 901, "as a win for that knight"); + // Four cycles or fewer, two or fewer, and nobody lost: three cycles meets two. + check(settle.finished_target_list.size() == 2, "three cycles meets two targets of three"); + } + + client.sendEmpty(cmd::GetCurChallengePeakCsReq); + check(client.await(cmd::GetCurChallengePeakScRsp, packet), "the fight is queryable"); + proto::GetCurChallengePeakScRsp cur; + check(parseBody(packet, cur), "the fight parses"); + check(cur.peak_id == 901, "and it is the knight"); + + client.sendEmpty(cmd::ReStartChallengePeakCsReq); + check(client.await(cmd::ReStartChallengePeakScRsp, packet), "retrying answers"); + proto::ReStartChallengePeakScRsp restart; + check(parseBody(packet, restart), "the retry parses"); + check(restart.retcode == 0, "and was accepted"); + arenaOf("the arena went out again", knight, monsters, actors, entryId); + check(monsters == 1, "with the knight standing again"); + + client.sendEmpty(cmd::LeaveChallengePeakCsReq); + check(client.await(cmd::LeaveChallengePeakScRsp, packet), "leaving answers"); + check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back"); + + proto::SetChallengePeakBossHardModeCsReq hardMode; + hardMode.peak_group_id = 10; + hardMode.is_hard_mode = true; + client.send(cmd::SetChallengePeakBossHardModeCsReq, hardMode); + check(client.await(cmd::SetChallengePeakBossHardModeScRsp, packet), "hard mode answers"); + proto::SetChallengePeakBossHardModeScRsp hardRsp; + check(parseBody(packet, hardRsp), "hard mode parses"); + check(hardRsp.is_hard_mode && hardRsp.peak_group_id == 10, "and is echoed"); + + proto::StartChallengePeakCsReq bossStart; + bossStart.peak_id = 1004; + bossStart.peak_avatar_id_list = {8001, 1001}; + client.send(cmd::StartChallengePeakCsReq, bossStart); + check(client.await(cmd::StartChallengePeakScRsp, packet), "starting the boss answers"); + check(parseBody(packet, startRsp) && startRsp.retcode == 0, "and was accepted"); + uint32_t boss = 0; + arenaOf("the boss arena went out", boss, monsters, actors, entryId); + check(entryId == 3013501, "borrowing season one's arena"); + check(monsters == 1, "with the boss alone in it"); + + if (boss != 0 && fight(boss, 1, battle)) { + check(battle.stage_id == 30510022, "against the boss's hard stage"); + check(targetsIn(battle) == 1, "judged by the hard target alone"); + + net::Packet settlePacket; + check(client.await(cmd::ChallengePeakSettleScNotify, settlePacket), + "winning settles the boss"); + proto::ChallengePeakSettleScNotify settle; + check(parseBody(settlePacket, settle), "the settle parses"); + check(settle.is_win && settle.hard_mode_has_passed, "as a hard clear"); + } + + client.sendEmpty(cmd::LeaveChallengePeakCsReq); + check(client.await(cmd::LeaveChallengePeakScRsp, packet), "leaving the boss answers"); + check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back"); + + client.sendEmpty(cmd::GetCurChallengePeakCsReq); + check(client.await(cmd::GetCurChallengePeakScRsp, packet), "the run is queryable"); + proto::GetCurChallengePeakScRsp after; + check(parseBody(packet, after), "the run parses"); + check(after.peak_id == 0, "and there is no longer one going"); + } + // Every way out of a fight answers. None of these did before, which left the // client sitting on the defeat screen or on a half-switched party. client.sendEmpty(cmd::QuitBattleCsReq); diff --git a/tests/test_game.cpp b/tests/test_game.cpp index 70826d2..92c9b52 100644 --- a/tests/test_game.cpp +++ b/tests/test_game.cpp @@ -18,6 +18,7 @@ #include "game/roster.h" #include "game/scene.h" #include "game/srtools.h" +#include "game/tierce.h" #include "net/packet.h" #include "tests/harness.h" @@ -213,6 +214,27 @@ void testChallengeHistory() { check(rsp.max_level_list.size() == 3, "one max level per mode"); check(rsp.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage, "the challenge history fits in one kcp message"); + + // Anomaly Arbitration. Production rows have no arena, so the beta dump fills it in, + // and places season ten, which production does not have at all. + check(tables.peakGroups().size() >= 10, "every arbitration season loaded"); + const data::PeakInfo* knight = tables.peak(101); + check(knight != nullptr && knight->mapEntranceId == 3013501 && knight->eventId == 30501011, + "a production knight gets its arena from the beta dump"); + check(knight != nullptr && knight->monsters.size() == 1 && + knight->monsters[0].configId == data::kPeakMarkerId, + "and plants one monster over the arena's marker"); + const data::PeakInfo* boss = tables.peak(1004); + check(boss != nullptr && boss->boss && boss->groupId == 10, "1004 is season ten's boss"); + check(boss != nullptr && boss->mapEntranceId == 3014601 && boss->mazeGroupId == 7, + "on its own arena"); + check(boss != nullptr && boss->hardEventId == 30510022 && boss->hardTarget == 3007, + "with a hard stage and target of its own"); + const data::BattleTargetInfo* turns = tables.battleTarget(3001); + check(turns != nullptr && turns->param == 4 && !turns->countsDeaths, + "target 3001 is four cycles or fewer"); + const data::BattleTargetInfo* deaths = tables.battleTarget(3000); + check(deaths != nullptr && deaths->countsDeaths, "target 3000 counts avatars lost"); } void testSceneRes() { @@ -500,7 +522,6 @@ void testBattleBuild() { request.casterEntityId = 1; request.skillIndex = 0; request.monsterEntityIds = {monsterEntity}; - request.allowSrToolsOverride = false; proto::SceneBattleInfo info = game::battle::create(player, request); check(info.battle_id != 0, "the fight got an id"); @@ -565,6 +586,115 @@ void testBattleBuild() { check(!stillThere, "and can be switched off"); } +// The third node 4.6 bolts onto a floor: its own arena and monster, everything else +// borrowed from the floor it extends. +void testTierceRun() { + if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return; + const data::Tables& tables = data::Tables::get(); + + const data::ChallengeTierceInfo* config = tables.challengeTierce(5213); + check(config != nullptr, "the tierce table is loaded"); + if (config == nullptr) return; + check(config->preChallengeId == 5212, "5213 extends Memory of Chaos floor 5212"); + check(config->roundLimit == 45, "with a cycle pool for the whole floor"); + check(config->mazeGroupId == 11, "its node is scene group 11"); + check(config->monsters.size() == 1, "holding one planted monster"); + check(config->targetIds.size() == 3, "and three star targets"); + check(tables.challengeTierceFor(5212) == config, "the floor finds it the other way too"); + + const data::ChallengeInfo* floor = tables.challenge(config->preChallengeId); + check(floor != nullptr, "the floor it extends is loaded"); + if (floor == nullptr) return; + + game::Player player = makeTestPlayer(); + std::vector stages(3); + for (uint32_t stage = 0; stage < 3; ++stage) { + proto::AvatarIdentifier one; + one.id = 8001 + stage; + stages[stage].lineup.push_back(one); + stages[stage].buff_id = 1000 + stage; + } + + proto::SceneInfo scene; + uint32_t retcode = game::tierce::start(player, 5213, false, 0, stages, scene); + check(retcode == 0, "the floor starts"); + if (retcode != 0) return; + check(player.tierce().active, "the run is recorded on the player"); + check(player.tierce().stage == 0, "on its first node"); + check(player.tierce().roundsLeft == 45, "with the whole cycle pool"); + check(player.location().entryId == floor->mapEntranceId, + "and the first node is the floor's own arena"); + + // The third node is the one the tierce row plants, not one of the floor's two. + player.tierce().stage = 2; + game::ChallengeArena storage; + const game::ChallengeArena* arena = game::tierce::arena(player, storage); + check(arena != nullptr && arena->mazeGroupId == config->mazeGroupId, + "the last node fights in the tierce's own group"); + check(arena != nullptr && arena->monsters == &config->monsters, + "with the tierce's own monster"); + player.tierce().stage = 0; + + // What the node brings to a fight comes from both rows. + game::BattleRequest request; + game::tierce::prepareBattle(player, request); + check(request.party.size() == 1, "the node fights with its own team"); + check(request.mazeBuffId == floor->mazeBuffId, "under the floor's maze buff"); + check(request.stageBuffId == 1000, "and the buff picked for this node"); + check(request.roundsLimit == 45, "with the cycles left in the pool"); + + // Starting again keeps the spot the first attempt left from. + game::SceneLocation origin = player.tierce().origin; + proto::SceneInfo again; + check(game::tierce::start(player, 5213, false, 0, stages, again) == 0, "a retry starts"); + check(player.tierce().origin.entryId == origin.entryId, "and goes back to the same place"); + + // Every floor with a third node shows up in the history, under the tierce's id. + proto::GetChallengeTierceDataScRsp history = game::tierce::history(player); + bool found = false; + for (const proto::ChallengeTierceData& entry : history.challenge_info_list) { + if (entry.challenge_id != 5213) continue; + found = true; + check(entry.stage_info_list.size() == 3, "with a slot for each of its three nodes"); + } + check(found, "the floor is in the history"); +} + +// "auto" hands a calyx over to the srtools build and leaves every other fight on the +// stage the client named. See gameplay.battle_source. +void testBattleSourceSplit() { + if (!data::Tables::get().loaded()) return; + if (core::Config::get().gameplay.battleSource != "auto") return; + + game::Player player = makeTestPlayer(); + game::Roster roster = player.roster(); + const game::BattleConfig& build = roster.data().battle; + if (build.waves.empty() || build.stageId == 0) { + std::printf("SKIP battle source split (the build on disk names no fight)\n"); + return; + } + const data::CocoonInfo* cocoon = data::Tables::get().cocoon(1001, player.worldLevel()); + if (cocoon == nullptr || cocoon->stageIds.empty()) { + std::printf("SKIP battle source split (calyx 1001 is not in the tables)\n"); + return; + } + uint32_t calyxStage = cocoon->stageIds.front(); + check(calyxStage != build.stageId, "the calyx and the build name different stages"); + + game::BattleRequest calyx; + calyx.cocoonId = 1001; + calyx.wave = 1; + calyx.stageIds.push_back(calyxStage); + calyx.allowSrToolsOverride = true; + proto::SceneBattleInfo run = game::battle::create(player, calyx); + check(run.stage_id == build.stageId, "a calyx fights the srtools build"); + + game::BattleRequest shadow; + shadow.stageIds.push_back(calyxStage); + proto::SceneBattleInfo other = game::battle::create(player, shadow); + check(other.stage_id == calyxStage, "every other fight keeps its own stage"); +} + // Writing an equip change back must not drop the parts of the build we do not // model -- the loadout presets are the user's, not ours. void testSrToolsRoundTrip() { @@ -672,6 +802,9 @@ void runGameTests() { std::string root = util::findRootDir("config/config.json"); if (!root.empty()) util::setCurrentDir(root); core::Config::get().load("config/config.json"); + // Anything that saves goes to a scratch file: a test must never write the save a + // real client is using. + core::Config::get().paths.playerFile = "build/test-player.json"; data::Tables::get().load(core::Config::get().paths.dataSources); data::SceneRes::get().load(core::Config::get().paths.sceneRes); data::SceneRes::get().loadAnchors(core::Config::get().paths.anchors); @@ -688,8 +821,10 @@ void runGameTests() { testRosterProtos(); testSceneBuild(); testChallengeRun(); + testTierceRun(); testEveryScenePacketFits(); testBattleBuild(); + testBattleSourceSplit(); testPropInteraction(); testSrToolsRoundTrip(); }