feat(game): add the new challenge modes

This commit is contained in:
capyb2222 committed 2026-09-11 20:01:23 -07:00
1 parent e77d7600ae
commit 9dd37f8087
31 files changed
+2536 -70

No files matched your search

+12 -3
View File
@@ -1,8 +1,16 @@
{ {
"server": { "server": {
"name": "CapySR", "name": "CapySR",
"http": { "bind": "0.0.0.0", "port": 21000, "public_host": "127.0.0.1" }, "http": {
"game": { "bind": "0.0.0.0", "port": 23301, "public_host": "127.0.0.1" } "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": { "log": {
"level": "debug", "level": "debug",
@@ -35,7 +43,8 @@
"global_buffs": true, "global_buffs": true,
"unlock_all_challenges": true, "unlock_all_challenges": true,
"main_character": 8008, "main_character": 8008,
"march_type": 1224 "march_type": 1224,
"skip_missions": []
}, },
"paths": { "paths": {
"srtools_file": "data/freesr-data.json", "srtools_file": "data/freesr-data.json",
+47
View File
@@ -140,6 +140,12 @@ UpdateGroupPropertyCsReq
UpdateGroupPropertyScRsp UpdateGroupPropertyScRsp
GetFirstTalkNpcCsReq GetFirstTalkNpcCsReq
GetFirstTalkNpcScRsp GetFirstTalkNpcScRsp
GetFirstTalkByPerformanceNpcCsReq
GetFirstTalkByPerformanceNpcScRsp
FinishFirstTalkNpcCsReq
FinishFirstTalkNpcScRsp
FinishFirstTalkByPerformanceNpcCsReq
FinishFirstTalkByPerformanceNpcScRsp
GetNpcTakenRewardCsReq GetNpcTakenRewardCsReq
GetNpcTakenRewardScRsp GetNpcTakenRewardScRsp
ActiveFarmElementCsReq ActiveFarmElementCsReq
@@ -195,6 +201,47 @@ TakeChallengeRewardScRsp
ChallengeLineupNotify ChallengeLineupNotify
ChallengeSettleNotify 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 ---- # ---- inventory ----
GetBagCsReq GetBagCsReq
GetBagScRsp GetBagScRsp
+1
View File
@@ -104,6 +104,7 @@ bool Config::load(const std::string& path) {
pick(*it, "unlock_all_challenges", gameplay.unlockAllChallenges); pick(*it, "unlock_all_challenges", gameplay.unlockAllChallenges);
pick(*it, "main_character", gameplay.mainCharacter); pick(*it, "main_character", gameplay.mainCharacter);
pick(*it, "march_type", gameplay.marchType); pick(*it, "march_type", gameplay.marchType);
pick(*it, "skip_missions", gameplay.skipMissions);
} }
return true; return true;
} }
+13 -2
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -58,8 +59,8 @@ struct PathsConfig {
}; };
struct GameplayConfig { struct GameplayConfig {
// auto = a srtools build replaces overworld fights; srtools = it replaces every // auto = a srtools build replaces calyx fights and nothing else; srtools = it
// fight; stage = never, always use the stage the client asked for. // replaces every fight; stage = never, always use the stage the client asked for.
std::string battleSource = "auto"; std::string battleSource = "auto";
bool globalBuffs = true; bool globalBuffs = true;
// Report every challenge floor as a full clear. A floor with no stars locks the one // 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; bool unlockAllChallenges = true;
uint32_t mainCharacter = 8008; uint32_t mainCharacter = 8008;
uint32_t marchType = 1224; 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<uint32_t> skipMissions;
bool missionSkipped(uint32_t id) const {
return std::find(skipMissions.begin(), skipMissions.end(), id) != skipMissions.end();
}
}; };
struct Config { struct Config {
+126 -2
View File
@@ -226,6 +226,23 @@ const T* findById(const std::vector<T>& sorted, uint32_t id) {
const ChallengeInfo* Tables::challenge(uint32_t id) const { return findById(challenges_, 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<uint32_t>* 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 { const ChallengeGroupInfo* Tables::challengeGroup(uint32_t groupId) const {
return findById(challengeGroups_, groupId); return findById(challengeGroups_, groupId);
} }
@@ -235,6 +252,18 @@ const ChallengeTarget* Tables::challengeTarget(uint32_t id) const {
return it == challengeTargets_.end() ? nullptr : &it->second; 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 { uint64_t Tables::challengeRewardStars(uint32_t rewardLineGroupId) const {
auto it = challengeRewardStars_.find(rewardLineGroupId); auto it = challengeRewardStars_.find(rewardLineGroupId);
return it == challengeRewardStars_.end() ? 0 : it->second; return it == challengeRewardStars_.end() ? 0 : it->second;
@@ -545,6 +574,31 @@ bool Tables::load(const std::vector<std::string>& sources) {
challengeFloors("ChallengeStoryMazeConfig.json", ChallengeKind::Story); challengeFloors("ChallengeStoryMazeConfig.json", ChallengeKind::Story);
challengeFloors("ChallengeBossMazeConfig.json", ChallengeKind::Boss); 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<uint32_t> configs = u32List(*row, "ConfigList");
std::vector<uint32_t> npcs = u32List(*row, "NpcMonsterIDList");
std::vector<uint32_t> 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. // Pure Fiction keeps its turn limit and its score targets one table over.
for (const json* row : table("ChallengeStoryMazeExtra.json")) { for (const json* row : table("ChallengeStoryMazeExtra.json")) {
uint32_t id = u32(*row, "ID"); uint32_t id = u32(*row, "ID");
@@ -594,6 +648,61 @@ bool Tables::load(const std::vector<std::string>& sources) {
challengeRewards("ChallengeStoryRewardLine.json"); challengeRewards("ChallengeStoryRewardLine.json");
challengeRewards("ChallengeBossRewardLine.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<uint32_t> npcs = u32List(*row, "NpcMonsterIDList");
if (!npcs.empty()) peak.npcMonsterId = npcs.back();
}
if (peak.eventId == 0) {
std::vector<uint32_t> 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<uint32_t> 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<uint32_t>& 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")) { for (const json* row : table("InteractConfig.json")) {
uint32_t id = u32(*row, "InteractID"); uint32_t id = u32(*row, "InteractID");
if (id == 0 || interacts_.count(id) != 0) continue; if (id == 0 || interacts_.count(id) != 0) continue;
@@ -634,6 +743,21 @@ bool Tables::load(const std::vector<std::string>& sources) {
}; };
sortById(challenges_); sortById(challenges_);
sortById(challengeGroups_); 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_) { for (ChallengeInfo& floor : challenges_) {
auto extra = challengeExtras_.find(floor.id); auto extra = challengeExtras_.find(floor.id);
@@ -653,8 +777,8 @@ bool Tables::load(const std::vector<std::string>& sources) {
"{} plane events from {} source(s) in {} ms", "{} plane events from {} source(s) in {} ms",
avatars_.size(), skillPointCount_, stages_.size(), monsters_.size(), avatars_.size(), skillPointCount_, stages_.size(), monsters_.size(),
entrances_.size(), planeEvents_.size(), used, util::nowMs() - started); entrances_.size(), planeEvents_.size(), used, util::nowMs() - started);
logging::info("data", "{} challenge floors in {} seasons", logging::info("data", "{} challenge floors in {} seasons, {} of them with a third node",
challenges_.size(), challengeGroups_.size()); challenges_.size(), challengeGroups_.size(), challengeTierces_.size());
logging::info("data", "{} main missions, {} tutorials, {} guides, {} quests", logging::info("data", "{} main missions, {} tutorials, {} guides, {} quests",
mainMissions_.size(), tutorials_.size(), tutorialGuides_.size(), mainMissions_.size(), tutorials_.size(), tutorialGuides_.size(),
quests_.size()); quests_.size());
+71
View File
@@ -127,6 +127,20 @@ struct ChallengeInfo {
ChallengeStage stages[2]; 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<uint32_t> targetIds;
std::vector<ChallengeMonster> monsters;
};
// How one star is earned. MoC counts cycles left and deaths; the other two only score. // How one star is earned. MoC counts cycles left and deaths; the other two only score.
struct ChallengeTarget { struct ChallengeTarget {
uint32_t id = 0; uint32_t id = 0;
@@ -142,6 +156,47 @@ struct ChallengeGroupInfo {
ChallengeKind kind = ChallengeKind::Memory; 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<uint32_t> targetIds; // NormalTargetList
std::vector<uint32_t> tagBuffs; // the enemy tags, TagList
std::vector<ChallengeMonster> monsters;
// The boss only: the buffs the player picks one of, and what hard mode swaps in.
bool boss = false;
std::vector<uint32_t> bossBuffs;
uint32_t hardTarget = 0;
uint32_t hardEventId = 0;
std::vector<uint32_t> hardTagBuffs;
std::vector<ChallengeMonster> hardMonsters;
};
// One Anomaly Arbitration season: three knights and a boss.
struct PeakGroupInfo {
uint32_t id = 0;
std::vector<uint32_t> 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. // MazePlane.PlaneType strings, in the numbering SceneInfo.game_mode_type uses.
uint32_t planeTypeFromName(const std::string& name); uint32_t planeTypeFromName(const std::string& name);
@@ -205,6 +260,9 @@ public:
// Every floor and season of the three challenge modes, in id order. // Every floor and season of the three challenge modes, in id order.
const std::vector<ChallengeInfo>& challenges() const { return challenges_; } const std::vector<ChallengeInfo>& 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<ChallengeGroupInfo>& challengeGroups() const { return challengeGroups_; } const std::vector<ChallengeGroupInfo>& challengeGroups() const { return challengeGroups_; }
const ChallengeInfo* challenge(uint32_t id) const; const ChallengeInfo* challenge(uint32_t id) const;
const ChallengeGroupInfo* challengeGroup(uint32_t groupId) 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. // Bit n set for every star count the reward line of that group pays out at.
uint64_t challengeRewardStars(uint32_t rewardLineGroupId) const; uint64_t challengeRewardStars(uint32_t rewardLineGroupId) const;
// Anomaly Arbitration seasons in id order, and the fights they are made of.
const std::vector<PeakGroupInfo>& 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<uint32_t>* peakRewards(uint32_t rewardGroupId) const;
const BattleTargetInfo* battleTarget(uint32_t id) const;
const std::unordered_map<uint32_t, AvatarInfo>& avatars() const { return avatars_; } const std::unordered_map<uint32_t, AvatarInfo>& avatars() const { return avatars_; }
const std::unordered_map<uint32_t, EntranceInfo>& entrances() const { return entrances_; } const std::unordered_map<uint32_t, EntranceInfo>& entrances() const { return entrances_; }
@@ -240,6 +306,7 @@ private:
std::vector<uint32_t> tutorialGuides_; std::vector<uint32_t> tutorialGuides_;
std::vector<uint32_t> quests_; std::vector<uint32_t> quests_;
std::vector<ChallengeInfo> challenges_; std::vector<ChallengeInfo> challenges_;
std::unordered_map<uint32_t, ChallengeTierceInfo> challengeTierces_;
std::vector<ChallengeGroupInfo> challengeGroups_; std::vector<ChallengeGroupInfo> challengeGroups_;
std::unordered_map<uint32_t, ChallengeTarget> challengeTargets_; std::unordered_map<uint32_t, ChallengeTarget> challengeTargets_;
// ChallengeStoryMazeExtra, held only until load() folds it into the floors. // ChallengeStoryMazeExtra, held only until load() folds it into the floors.
@@ -249,6 +316,10 @@ private:
}; };
std::unordered_map<uint32_t, ChallengeExtra> challengeExtras_; std::unordered_map<uint32_t, ChallengeExtra> challengeExtras_;
std::unordered_map<uint32_t, uint64_t> challengeRewardStars_; std::unordered_map<uint32_t, uint64_t> challengeRewardStars_;
std::vector<PeakGroupInfo> peakGroups_;
std::unordered_map<uint32_t, PeakInfo> peaks_;
std::unordered_map<uint32_t, std::vector<uint32_t>> peakRewards_;
std::unordered_map<uint32_t, BattleTargetInfo> battleTargets_;
size_t skillPointCount_ = 0; size_t skillPointCount_ = 0;
bool loaded_ = false; bool loaded_ = false;
}; };
+26 -9
View File
@@ -56,7 +56,7 @@ bool useSrToolsBattle(const BattleConfig& config, bool allowOverride) {
const std::string& source = core::Config::get().gameplay.battleSource; const std::string& source = core::Config::get().gameplay.battleSource;
if (source == "stage") return false; if (source == "stage") return false;
if (source == "srtools") return true; 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 // 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") { } else if (battleType == "AS") {
target(1, 90005); 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)) { if (useSrToolsBattle(config, request.allowSrToolsOverride)) {
info.stage_id = config.stageId; info.stage_id = config.stageId;
info.rounds_limit = config.cycleCount; info.rounds_limit = config.cycleCount;
for (const std::vector<BattleMonster>& wave : config.waves) { // A calyx sweep buys several runs, one fight each, so the build's waves repeat.
std::vector<uint32_t> ids; for (uint32_t run = 0; run < std::max(1u, request.wave); ++run) {
uint32_t level = 1; for (const std::vector<BattleMonster>& wave : config.waves) {
for (const BattleMonster& monster : wave) { std::vector<uint32_t> ids;
for (uint32_t n = 0; n < std::max(1u, monster.amount); ++n) { uint32_t level = 1;
ids.push_back(monster.monsterId); 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) { for (const BattleBuff& blessing : config.blessings) {
proto::BattleBuff buff = makeBuff(blessing.id, blessing.level, kNoOwner); 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}) { for (uint32_t buffId : {request.mazeBuffId, request.stageBuffId}) {
if (buffId != 0) info.buff_list.push_back(makeBuff(buffId, 1, kNoOwner)); 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()) { if (!request.battleType.empty()) {
addBattleTargets(info, request.battleType, request.battleTargetIds, request.scoreSoFar); addBattleTargets(info, request.battleType, request.battleTargetIds, request.scoreSoFar);
} }
+7 -4
View File
@@ -20,8 +20,9 @@ struct BattleRequest {
std::vector<uint32_t> stageIds; std::vector<uint32_t> stageIds;
uint32_t cocoonId = 0; uint32_t cocoonId = 0;
uint32_t wave = 0; uint32_t wave = 0;
// Calyx and challenges keep their own stage even when a srtools build is loaded. // Only a calyx hands its fight over to a srtools build; everything else keeps the
bool allowSrToolsOverride = true; // stage it named. See gameplay.battle_source.
bool allowSrToolsOverride = false;
// ---- challenges ---------------------------------------------------------- // ---- challenges ----------------------------------------------------------
// The team fighting this node, when it is not the squad the player walks around // 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. // The floor's own maze buff, and the one the player picked for this half.
uint32_t mazeBuffId = 0; uint32_t mazeBuffId = 0;
uint32_t stageBuffId = 0; uint32_t stageBuffId = 0;
// Anomaly Arbitration's enemy tags and the boss buff the player picked.
std::vector<uint32_t> floorBuffIds;
// MoC's cycle limit, counted down across the whole floor. // MoC's cycle limit, counted down across the whole floor.
uint32_t roundsLimit = 0; uint32_t roundsLimit = 0;
// "PF" or "AS": the two scored modes need a win condition of their own or the // "PF", "AS" or "AA": modes that need a win condition of their own, or the client
// client hangs on the last turn with nothing to settle. // hangs on the last turn with nothing to settle.
std::string battleType; std::string battleType;
// Score carried in from the first half, and Pure Fiction's per-wave targets. // Score carried in from the first half, and Pure Fiction's per-wave targets.
uint32_t scoreSoFar = 0; uint32_t scoreSoFar = 0;
+12 -10
View File
@@ -7,8 +7,10 @@
#include "game/challenge.h" #include "game/challenge.h"
#include "game/handlers.h" #include "game/handlers.h"
#include "game/notify.h" #include "game/notify.h"
#include "game/peak.h"
#include "game/player.h" #include "game/player.h"
#include "game/scene.h" #include "game/scene.h"
#include "game/tierce.h"
#include "net/cmd_ids.h" #include "net/cmd_ids.h"
#include "net/handler.h" #include "net/handler.h"
#include "net/session.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; if (skillIndex == 0 || casterEntityId == 0) return false;
// A challenge node is not cleared by walking through it, and the caster would be // 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. // 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<uint32_t> party = player.lineups().curMembers(); std::vector<uint32_t> party = player.lineups().curMembers();
if (casterEntityId > party.size()) return false; if (casterEntityId > party.size()) return false;
uint32_t avatarId = party[casterEntityId - 1]; uint32_t avatarId = party[casterEntityId - 1];
@@ -62,7 +64,6 @@ void applyChallenge(const Player& player, BattleRequest& request) {
request.roundsLimit = run.roundsLeft; request.roundsLimit = run.roundsLeft;
request.scoreSoFar = run.totalScore(); request.scoreSoFar = run.totalScore();
request.battleTargetIds = config->battleTargetIds; request.battleTargetIds = config->battleTargetIds;
request.allowSrToolsOverride = false;
switch (config->kind) { switch (config->kind) {
case data::ChallengeKind::Story: case data::ChallengeKind::Story:
request.battleType = "PF"; request.battleType = "PF";
@@ -153,6 +154,8 @@ void onSceneCastSkill(net::Session& session, const proto::SceneCastSkillCsReq& r
request.skillIndex = req.skill_index; request.skillIndex = req.skill_index;
request.monsterEntityIds = monsters; request.monsterEntityIds = monsters;
applyChallenge(*player, request); applyChallenge(*player, request);
tierce::prepareBattle(*player, request);
peak::prepareBattle(*player, request);
rsp.battle_info = battle::create(*player, request); rsp.battle_info = battle::create(*player, request);
report(monsters, proto::MonsterBattleType::MONSTER_BATTLE_TYPE_TRIGGER_BATTLE); report(monsters, proto::MonsterBattleType::MONSTER_BATTLE_TYPE_TRIGGER_BATTLE);
session.send(cmd::SceneCastSkillScRsp, rsp); session.send(cmd::SceneCastSkillScRsp, rsp);
@@ -178,14 +181,14 @@ void onSceneCastSkillCostMp(net::Session& session, const proto::SceneCastSkillCo
session.send(cmd::SceneCastSkillCostMpScRsp, rsp); session.send(cmd::SceneCastSkillCostMpScRsp, rsp);
} }
// Calyx and the other farm stages: the cocoon names the stage, so a srtools build // Calyx: the one fight a srtools build is allowed to take over, monsters, blessings and
// must not replace it. `wave` is how many runs were bought, one stage each. // all. `wave` is how many runs were bought, one stage each.
proto::SceneBattleInfo cocoonBattle(Player& player, uint32_t cocoonId, uint32_t wave, proto::SceneBattleInfo cocoonBattle(Player& player, uint32_t cocoonId, uint32_t wave,
uint32_t worldLevel) { uint32_t worldLevel) {
BattleRequest request; BattleRequest request;
request.cocoonId = cocoonId; request.cocoonId = cocoonId;
request.wave = std::max(1u, wave); request.wave = std::max(1u, wave);
request.allowSrToolsOverride = false; request.allowSrToolsOverride = true;
const data::CocoonInfo* cocoon = data::Tables::get().cocoon(cocoonId, worldLevel); const data::CocoonInfo* cocoon = data::Tables::get().cocoon(cocoonId, worldLevel);
if (cocoon == nullptr || cocoon->stageIds.empty()) { 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, // 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, void onQuickStartFarmElement(net::Session& session,
const proto::QuickStartFarmElementCsReq& req) { const proto::QuickStartFarmElementCsReq& req) {
Player* player = playerOf(session, "QuickStartFarmElement"); 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(); uint32_t worldLevel = req.world_level != 0 ? req.world_level : player->worldLevel();
BattleRequest request; BattleRequest request;
request.allowSrToolsOverride = false;
uint32_t stageId = data::Tables::get().farmElementStage(req.PAOFHFLFFHD, worldLevel); uint32_t stageId = data::Tables::get().farmElementStage(req.PAOFHFLFFHD, worldLevel);
if (stageId != 0) request.stageIds.push_back(stageId); if (stageId != 0) request.stageIds.push_back(stageId);
@@ -318,10 +320,8 @@ void onReEnterLastElementStage(net::Session& session,
Player* player = playerOf(session, "ReEnterLastElementStage"); Player* player = playerOf(session, "ReEnterLastElementStage");
if (player == nullptr) return; if (player == nullptr) return;
// The retry button names the stage it wants replayed, so a srtools build must not // The retry button names the stage it wants replayed.
// substitute its own -- same rule as a calyx.
BattleRequest request; BattleRequest request;
request.allowSrToolsOverride = false;
if (req.stage_id != 0) request.stageIds.push_back(req.stage_id); if (req.stage_id != 0) request.stageIds.push_back(req.stage_id);
proto::ReEnterLastElementStageScRsp rsp; 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 monsters are gone from the scene by now, which is how the run tells whether
// the node it was fighting is finished. // the node it was fighting is finished.
challenge::battleFinished(session, *player, req); 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. // The fight was simulated on the client; the server only confirms the outcome.
proto::PVEBattleResultScRsp rsp; proto::PVEBattleResultScRsp rsp;
+23 -15
View File
@@ -81,15 +81,15 @@ proto::GetChallengeScRsp history() {
return rsp; return rsp;
} }
proto::LineupInfo lineup(const Player& player, uint32_t stage) { proto::LineupInfo extraLineup(const Player& player, const std::vector<uint32_t>& party,
const std::vector<uint32_t>& party = player.challenge().party[half(stage)]; proto::ExtraLineupType type) {
Roster roster = player.roster(); Roster roster = player.roster();
proto::LineupInfo info; 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 // An extra lineup is addressed as its type plus ten, which is how the client keeps
// it out of the six ordinary squads. // it out of the six ordinary squads.
info.index = static_cast<uint32_t>(info.extra_lineup_type) + 10; info.index = static_cast<uint32_t>(type) + 10;
info.max_mp = maxMp(party); info.max_mp = maxMp(party);
info.mp = info.max_mp; info.mp = info.max_mp;
info.leader_slot = 0; info.leader_slot = 0;
@@ -101,6 +101,10 @@ proto::LineupInfo lineup(const Player& player, uint32_t stage) {
return info; 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) { proto::CurChallenge current(const Player& player) {
const ChallengeRun& run = player.challenge(); const ChallengeRun& run = player.challenge();
const data::ChallengeInfo* config = configOf(player); 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 ChallengeArena* arena(const Player& player, ChallengeArena& storage) {
const data::ChallengeInfo* config = configOf(player); const data::ChallengeInfo* config = configOf(player);
if (config == nullptr) return nullptr; if (config == nullptr) return nullptr;
const data::ChallengeStage& stage = config->stages[half(player.challenge().stage)]; uint32_t stage = player.challenge().stage;
storage.mazeGroupId = stage.mazeGroupId; storage.mazeGroupId = config->stages[half(stage)].mazeGroupId;
storage.monsters = &stage.monsters; storage.monsters = &config->stages[half(stage)].monsters;
storage.party = &player.challenge().party[half(stage)];
return &storage; return &storage;
} }
@@ -288,14 +293,7 @@ bool nextPhase(net::Session& session, Player& player, proto::SceneInfo& out) {
return true; return true;
} }
void leave(net::Session& session, Player& player) { void putBack(net::Session& session, Player& player, uint32_t entryId, const Position& at) {
ChallengeRun& run = player.challenge();
if (!run.active) return;
uint32_t entryId = run.origin.entryId;
Position at = run.originPos;
run = ChallengeRun{};
proto::SceneInfo scene; proto::SceneInfo scene;
if (!scene::load(player, entryId, 0, true, scene)) { if (!scene::load(player, entryId, 0, true, scene)) {
logging::warn("challenge", "cannot put the player back at entry {}", entryId); logging::warn("challenge", "cannot put the player back at entry {}", entryId);
@@ -315,5 +313,15 @@ void leave(net::Session& session, Player& player) {
player.saveNow(); 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 challenge
} // namespace game } // namespace game
+6
View File
@@ -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. // Ends the run and puts the player back where they started.
void leave(net::Session& session, Player& player); 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<uint32_t>& party,
proto::ExtraLineupType type);
void putBack(net::Session& session, Player& player, uint32_t entryId, const Position& at);
} // namespace challenge } // namespace challenge
} // namespace game } // namespace game
+3
View File
@@ -19,7 +19,10 @@ void registerLineupHandlers();
void registerSceneHandlers(); void registerSceneHandlers();
void registerBattleHandlers(); void registerBattleHandlers();
void registerMissionHandlers(); void registerMissionHandlers();
void registerTalkHandlers();
void registerChallengeHandlers(); void registerChallengeHandlers();
void registerTierceHandlers();
void registerPeakHandlers();
void registerModuleHandlers(); void registerModuleHandlers();
void registerMiscHandlers(); void registerMiscHandlers();
+32 -7
View File
@@ -12,6 +12,7 @@
#include "net/handler.h" #include "net/handler.h"
#include "net/session.h" #include "net/session.h"
#include "proto/gen/protos.h" #include "proto/gen/protos.h"
#include "sdk/routes.h"
namespace game { namespace game {
namespace { namespace {
@@ -123,16 +124,17 @@ void onGetActivityScheduleConfig(net::Session& session,
session.send(cmd::GetActivityScheduleConfigScRsp, rsp); session.send(cmd::GetActivityScheduleConfigScRsp, rsp);
} }
// A "CapySR" tag on the client's version label: Lua in ClientDownloadDataScNotify that // A "CapySR" tag on the client's version label, with the client version after it: Lua in
// rewrites the VersionText object. Only the name -- the native watermark (gateway field // ClientDownloadDataScNotify that rewrites the VersionText object. The native watermark
// 1660) stays off, it prints account data. // (gateway field 1660) stays off, it prints account data.
constexpr std::string_view kWatermarkText = "CapySR"; constexpr std::string_view kWatermarkText = "CapySR";
constexpr uint32_t kWatermarkFrom = 0xD08C4F; // caramel constexpr uint32_t kWatermarkFrom = 0xD08C4F; // caramel
constexpr uint32_t kWatermarkTo = 0xF6DDA0; // sand 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; std::string rich;
size_t n = kWatermarkText.size(); size_t n = text.size();
for (size_t i = 0; i < n; ++i) { for (size_t i = 0; i < n; ++i) {
double t = n > 1 ? static_cast<double>(i) / static_cast<double>(n - 1) : 0.0; double t = n > 1 ? static_cast<double>(i) / static_cast<double>(n - 1) : 0.0;
auto channel = [&](int shift) { auto channel = [&](int shift) {
@@ -144,9 +146,29 @@ std::string watermarkLua() {
std::snprintf(tag, sizeof(tag), "<color=#%02X%02X%02X>", channel(16), channel(8), std::snprintf(tag, sizeof(tag), "<color=#%02X%02X%02X>", channel(16), channel(8),
channel(0)); channel(0));
rich += tag; rich += tag;
rich += kWatermarkText[i]; rich += text[i];
rich += "</color>"; rich += "</color>";
} }
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" return "pcall(function()\n"
" local go = CS.UnityEngine.GameObject.Find(\"VersionText\")\n" " local go = CS.UnityEngine.GameObject.Find(\"VersionText\")\n"
" if go == nil then return end\n" " if go == nil then return end\n"
@@ -166,7 +188,7 @@ void sendWatermark(net::Session& session) {
auto& download = notify.download_data.emplace(); auto& download = notify.download_data.emplace();
download.version = ++version; download.version = ++version;
download.time = static_cast<int64_t>(util::nowMs() / 1000); download.time = static_cast<int64_t>(util::nowMs() / 1000);
download.data = watermarkLua(); download.data = watermarkLua(sdk::lastClientVersion());
session.send(cmd::ClientDownloadDataScNotify, notify); session.send(cmd::ClientDownloadDataScNotify, notify);
} }
@@ -200,7 +222,10 @@ void registerAllHandlers() {
registerSceneHandlers(); registerSceneHandlers();
registerBattleHandlers(); registerBattleHandlers();
registerMissionHandlers(); registerMissionHandlers();
registerTalkHandlers();
registerChallengeHandlers(); registerChallengeHandlers();
registerTierceHandlers();
registerPeakHandlers();
registerModuleHandlers(); registerModuleHandlers();
registerMiscHandlers(); registerMiscHandlers();
logging::info("game", "{} packet handlers registered, {} answered empty", logging::info("game", "{} packet handlers registered, {} answered empty",
+17 -5
View File
@@ -7,6 +7,7 @@
// //
// This is also what unlocks the world: the client gates map regions on finished main // This is also what unlocks the world: the client gates map regions on finished main
// missions. // missions.
#include "core/config.h"
#include "core/logger.h" #include "core/logger.h"
#include "data/excel.h" #include "data/excel.h"
#include "game/handlers.h" #include "game/handlers.h"
@@ -18,9 +19,20 @@
namespace game { namespace game {
namespace { namespace {
void onGetMissionStatus(net::Session& session, const proto::GetMissionStatusCsReq& req) { // Everything finished, bar the handful config names -- see gameplay.skip_missions.
const data::Tables& tables = data::Tables::get(); std::vector<uint32_t> finishedMainMissions() {
const core::GameplayConfig& gameplay = core::Config::get().gameplay;
const std::vector<uint32_t>& all = data::Tables::get().mainMissions();
if (gameplay.skipMissions.empty()) return all;
std::vector<uint32_t> 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; proto::GetMissionStatusScRsp rsp;
rsp.retcode = 0; rsp.retcode = 0;
// Whatever it asked about is finished. There are ~15k sub-missions in the tables, // 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; mission.progress = 1;
rsp.sub_mission_status_list.push_back(mission); 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. // 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); 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&) { void onGetMissionData(net::Session& session, const proto::GetMissionDataCsReq&) {
proto::GetMissionDataScRsp rsp; proto::GetMissionDataScRsp rsp;
rsp.retcode = 0; 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); session.send(cmd::GetMissionDataScRsp, rsp);
} }
+359
View File
@@ -0,0 +1,359 @@
#include "game/peak.h"
#include <algorithm>
#include <utility>
#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<uint32_t> 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<uint32_t>& 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<uint32_t>& teamOf(const PeakProgress& progress, uint32_t peakId) {
static const std::vector<uint32_t> 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<proto::ChallengePeakRecordAvatar> recordAvatars(const std::vector<uint32_t>& team) {
std::vector<proto::ChallengePeakRecordAvatar> 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<uint32_t>(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<uint32_t>(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<uint32_t>* 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<uint32_t> 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<uint32_t> targets = targetsOf(*config, run.hard);
std::vector<uint32_t> 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
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <cstdint>
#include <vector>
#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<uint32_t> 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
+193
View File
@@ -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 <utility>
#include <vector>
#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<uint32_t> nonZero(const std::vector<uint32_t>& ids) {
std::vector<uint32_t> 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<uint32_t>& 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<proto::GetChallengePeakDataCsReq>(cmd::GetChallengePeakDataCsReq,
onGetChallengePeakData);
net::on<proto::GetCurChallengePeakCsReq>(cmd::GetCurChallengePeakCsReq, onGetCurChallengePeak);
net::on<proto::SetChallengePeakMobLineupAvatarCsReq>(cmd::SetChallengePeakMobLineupAvatarCsReq,
onSetChallengePeakMobLineupAvatar);
net::on<proto::SetChallengePeakBossHardModeCsReq>(cmd::SetChallengePeakBossHardModeCsReq,
onSetChallengePeakBossHardMode);
net::on<proto::StartChallengePeakCsReq>(cmd::StartChallengePeakCsReq, onStartChallengePeak);
net::on<proto::ReStartChallengePeakCsReq>(cmd::ReStartChallengePeakCsReq,
onReStartChallengePeak);
net::on<proto::LeaveChallengePeakCsReq>(cmd::LeaveChallengePeakCsReq, onLeaveChallengePeak);
net::on<proto::ConfirmChallengePeakSettleCsReq>(cmd::ConfirmChallengePeakSettleCsReq,
onConfirmChallengePeakSettle);
net::on<proto::TakeChallengePeakRewardCsReq>(cmd::TakeChallengePeakRewardCsReq,
onTakeChallengePeakReward);
}
} // namespace game
+89
View File
@@ -1,7 +1,9 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <map>
#include <memory> #include <memory>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -47,6 +49,81 @@ struct ChallengeRun {
const std::vector<uint32_t>& curParty() const { return party[stage > 1 ? 1 : 0]; } const std::vector<uint32_t>& 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<uint32_t> 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<uint32_t> party[3];
SceneLocation origin;
Position originPos;
uint32_t totalScore() const { return scores[0] + scores[1] + scores[2]; }
const std::vector<uint32_t>& 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<uint32_t> 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<uint32_t> targets;
};
// A cleared Anomaly Arbitration fight, as the overview shows it.
struct PeakRecord {
uint32_t cycles = 0;
std::vector<uint32_t> targets;
std::vector<uint32_t> 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<uint32_t, std::vector<uint32_t>> teams;
std::map<uint32_t, uint32_t> bossBuffs;
std::set<uint32_t> hardGroups;
std::map<uint32_t, PeakRecord> 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. // One logged-in account: identity, party, position and the current fight.
class Player { class Player {
public: public:
@@ -83,6 +160,14 @@ public:
const BattleContext& battle() const { return battle_; } const BattleContext& battle() const { return battle_; }
ChallengeRun& challenge() { return challenge_; } ChallengeRun& challenge() { return challenge_; }
const ChallengeRun& challenge() const { 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<uint32_t, TierceProgress>& tierceHistory() { return tierceHistory_; }
const std::map<uint32_t, TierceProgress>& tierceHistory() const { return tierceHistory_; }
PeakProgress& peakProgress() { return peakProgress_; }
const PeakProgress& peakProgress() const { return peakProgress_; }
uint32_t mainCharacter() const { return mainCharacter_; } uint32_t mainCharacter() const { return mainCharacter_; }
void setMainCharacter(uint32_t value) { mainCharacter_ = value; } void setMainCharacter(uint32_t value) { mainCharacter_ = value; }
@@ -120,6 +205,10 @@ private:
SceneState sceneState_; SceneState sceneState_;
BattleContext battle_; BattleContext battle_;
ChallengeRun challenge_; ChallengeRun challenge_;
PeakRun peak_;
PeakProgress peakProgress_;
TierceRun tierce_;
std::map<uint32_t, TierceProgress> tierceHistory_;
uint32_t mainCharacter_ = 8008; uint32_t mainCharacter_ = 8008;
uint32_t marchType_ = 1224; uint32_t marchType_ = 1224;
+104
View File
@@ -27,6 +27,16 @@ bool boolOr(const json& j, const char* key, bool fallback) {
return (it != j.end() && it->is_boolean()) ? it->get<bool>() : fallback; return (it != j.end() && it->is_boolean()) ? it->get<bool>() : fallback;
} }
std::vector<uint32_t> u32Array(const json& j, const char* key) {
std::vector<uint32_t> 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<uint32_t>());
}
return out;
}
} // namespace } // namespace
bool loadPlayerState(Player& player) { bool loadPlayerState(Player& player) {
@@ -104,6 +114,63 @@ bool loadPlayerState(Player& player) {
} }
book.setCurIndex(u32(j, "cur_squad", book.curIndex())); book.setCurIndex(u32(j, "cur_squad", book.curIndex()));
book.setMp(u32(j, "mp", book.maxMp())); 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<uint32_t> 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<uint32_t>());
}
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; return true;
} }
@@ -140,6 +207,43 @@ bool savePlayerState(const Player& player) {
{"entry_id", player.location().entryId}}}, {"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; const std::string& path = core::Config::get().paths.playerFile;
if (!util::writeFile(path, j.dump(2))) { if (!util::writeFile(path, j.dump(2))) {
logging::warn("player", "could not write {}", path); logging::warn("player", "could not write {}", path);
+22
View File
@@ -2,8 +2,10 @@
#include "core/logger.h" #include "core/logger.h"
#include "game/challenge.h" #include "game/challenge.h"
#include "game/peak.h"
#include "game/player.h" #include "game/player.h"
#include "game/scene.h" #include "game/scene.h"
#include "game/tierce.h"
#include "net/cmd_ids.h" #include "net/cmd_ids.h"
#include "net/session.h" #include "net/session.h"
#include "proto/gen/protos.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); logging::info("rescue", "uid {}: {}", player->uid(), did);
return 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) { if (player->challenge().active) {
player->challenge() = ChallengeRun{}; player->challenge() = ChallengeRun{};
note("abandoned the challenge"); 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; uint32_t target = entryId != 0 ? entryId : player->location().entryId;
proto::SceneInfo scene; proto::SceneInfo scene;
+22 -4
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include "core/config.h"
#include "core/logger.h" #include "core/logger.h"
#include "data/excel.h" #include "data/excel.h"
#include "data/scene_res.h" #include "data/scene_res.h"
@@ -105,13 +106,18 @@ proto::LineupInfo lineupInfo(const Player& player) {
} }
proto::SceneEntityGroupInfo actorGroup(Player& player, const Position& at) { 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<uint32_t>& members) {
proto::MotionInfo motion = toMotion(at); proto::MotionInfo motion = toMotion(at);
proto::SceneEntityGroupInfo group; proto::SceneEntityGroupInfo group;
group.group_id = 0; group.group_id = 0;
uint32_t slot = 0; uint32_t slot = 0;
for (uint32_t avatarId : player.lineups().curMembers()) { for (uint32_t avatarId : members) {
SceneEntity entity; SceneEntity entity;
entity.entityId = slot + 1; entity.entityId = slot + 1;
entity.kind = EntityKind::Actor; 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. // entrance's own anchor, then any pad on the floor.
Position spawn = player.position(); Position spawn = player.position();
const data::ResTeleport* anchor = teleportId != 0 ? floor->teleport(teleportId) : nullptr; 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); const data::EntranceInfo* entrance = data::Tables::get().entrance(entryId);
uint32_t anchorId = entrance != nullptr ? entrance->startAnchorId : 0; uint32_t anchorId = entrance != nullptr ? entrance->startAnchorId : 0;
anchor = data::SceneRes::get().anchor(entryId, anchorId); 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.game_mode_type = floor->planeType;
scene.world_id = fixWorldId(floor->worldId); scene.world_id = fixWorldId(floor->worldId);
scene.lighten_section_list = floor->sections; 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; scene.scene_identifier.emplace().floor_id = floor->floorId;
for (const auto& [name, value] : floor->savedValues) scene.floor_saved_data[name] = value; 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); 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) { for (uint32_t mission : group.finishedMainMissions) {
if (gameplay.missionSkipped(mission)) continue;
missions.finished_main_mission_id_list.push_back(mission); missions.finished_main_mission_id_list.push_back(mission);
} }
for (uint32_t mission : group.finishedSubMissions) { for (uint32_t mission : group.finishedSubMissions) {
if (gameplay.missionSkipped(mission)) continue;
proto::Mission entry; proto::Mission entry;
entry.id = mission; entry.id = mission;
entry.status = proto::MissionStatus::MissionStatus_MissionFinish; 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 // The party is group 0; its entity ids are slot + 1, which is what
// SceneCastSkill and leader_entity_id refer to. // 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); out = std::move(scene);
return true; return true;
+13 -8
View File
@@ -12,17 +12,18 @@ class Player;
// Positions are quantised to 1/1000 of a unit, the way the client sends them. // Positions are quantised to 1/1000 of a unit, the way the client sends them.
struct Position { struct Position {
int32_t x = -26968; int32_t x = 42548;
int32_t y = 78953; int32_t y = 3716;
int32_t z = 14457; int32_t z = -38422;
int32_t rotY = 11858; 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 { struct SceneLocation {
uint32_t planeId = 20411; uint32_t planeId = 20101;
uint32_t floorId = 20411001; uint32_t floorId = 20101001;
uint32_t entryId = 2041101; uint32_t entryId = 2010101;
}; };
enum class EntityKind : uint8_t { Actor, Npc, Monster, Prop }; enum class EntityKind : uint8_t { Actor, Npc, Monster, Prop };
@@ -79,6 +80,8 @@ proto::MotionInfo toMotion(const Position& position);
struct ChallengeArena { struct ChallengeArena {
uint32_t mazeGroupId = 0; uint32_t mazeGroupId = 0;
const std::vector<data::ChallengeMonster>* monsters = nullptr; const std::vector<data::ChallengeMonster>* monsters = nullptr;
// The team standing in it, which is not the squad the player walks around with.
const std::vector<uint32_t>* party = nullptr;
}; };
// Assembles SceneInfo out of the scene dump plus the current party. // 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. // 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);
proto::SceneEntityGroupInfo actorGroup(Player& player, const Position& at,
const std::vector<uint32_t>& members);
} // namespace scene } // namespace scene
} // namespace game } // namespace game
+6
View File
@@ -3,8 +3,10 @@
#include "data/scene_res.h" #include "data/scene_res.h"
#include "game/challenge.h" #include "game/challenge.h"
#include "game/handlers.h" #include "game/handlers.h"
#include "game/peak.h"
#include "game/player.h" #include "game/player.h"
#include "game/scene.h" #include "game/scene.h"
#include "game/tierce.h"
#include "net/cmd_ids.h" #include "net/cmd_ids.h"
#include "net/handler.h" #include "net/handler.h"
#include "net/session.h" #include "net/session.h"
@@ -32,6 +34,8 @@ void onGetCurSceneInfo(net::Session& session, const proto::GetCurSceneInfoCsReq&
// every monster the dump has in it. // every monster the dump has in it.
ChallengeArena storage; ChallengeArena storage;
const ChallengeArena* arena = challenge::arena(*player, 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)) { if (scene::load(*player, player->location().entryId, 0, false, info, arena)) {
rsp.scene = std::move(info); rsp.scene = std::move(info);
} else { } else {
@@ -49,6 +53,8 @@ void onGetCurSceneInfo(net::Session& session, const proto::GetCurSceneInfoCsReq&
void onEnterScene(net::Session& session, const proto::EnterSceneCsReq& req) { void onEnterScene(net::Session& session, const proto::EnterSceneCsReq& req) {
Player* player = playerOf(session, "EnterScene"); Player* player = playerOf(session, "EnterScene");
if (player == nullptr) return; 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; proto::SceneInfo info;
if (!scene::load(*player, req.entry_id, req.teleport_id, true, info)) { if (!scene::load(*player, req.entry_id, req.teleport_id, true, info)) {
+79
View File
@@ -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<proto::GetFirstTalkNpcCsReq>(cmd::GetFirstTalkNpcCsReq, onGetFirstTalkNpc);
net::on<proto::GetFirstTalkByPerformanceNpcCsReq>(cmd::GetFirstTalkByPerformanceNpcCsReq,
onGetFirstTalkByPerformanceNpc);
net::on<proto::FinishFirstTalkNpcCsReq>(cmd::FinishFirstTalkNpcCsReq, onFinishFirstTalkNpc);
net::on<proto::FinishFirstTalkByPerformanceNpcCsReq>(cmd::FinishFirstTalkByPerformanceNpcCsReq,
onFinishFirstTalkByPerformanceNpc);
net::on<proto::GetNpcTakenRewardCsReq>(cmd::GetNpcTakenRewardCsReq, onGetNpcTakenReward);
}
} // namespace game
+506
View File
@@ -0,0 +1,506 @@
#include "game/tierce.h"
#include <algorithm>
#include <utility>
#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<uint32_t> 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<uint32_t> 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<proto::BattleEndStatus>(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<uint32_t>(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<uint32_t> 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<proto::ChallengeTierceStageLineupInfo>& 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<uint32_t> 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<proto::ChallengeTierceStageLineupInfo>& 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<uint32_t> 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<proto::ChallengeTierceStageLineupInfo>& 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<uint32_t>(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<uint32_t>(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
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <cstdint>
#include <vector>
#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<proto::ChallengeTierceStageLineupInfo>& 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<proto::ChallengeTierceStageLineupInfo>& 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
+169
View File
@@ -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 <utility>
#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<proto::GetChallengeTierceDataCsReq>(cmd::GetChallengeTierceDataCsReq,
onGetChallengeTierceData);
net::on<proto::GetChallengeTierceControllerCsReq>(cmd::GetChallengeTierceControllerCsReq,
onGetChallengeTierceController);
net::on<proto::SetChallengeTierceLineupCsReq>(cmd::SetChallengeTierceLineupCsReq,
onSetChallengeTierceLineup);
net::on<proto::StartChallengeTierceCsReq>(cmd::StartChallengeTierceCsReq,
onStartChallengeTierce);
net::on<proto::StartNextChallengeTierceCsReq>(cmd::StartNextChallengeTierceCsReq,
onStartNextChallengeTierce);
net::on<proto::RestartChallengeTierceCsReq>(cmd::RestartChallengeTierceCsReq,
onRestartChallengeTierce);
net::on<proto::ConfirmChallengeTierceStageSettleCsReq>(
cmd::ConfirmChallengeTierceStageSettleCsReq, onConfirmChallengeTierceStageSettle);
net::on<proto::LeaveChallengeTierceCsReq>(cmd::LeaveChallengeTierceCsReq,
onLeaveChallengeTierce);
}
} // namespace game
+15
View File
@@ -1,5 +1,8 @@
#include "sdk/routes.h" #include "sdk/routes.h"
#include <mutex>
#include <string>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "core/config.h" #include "core/config.h"
@@ -14,6 +17,9 @@ using json = nlohmann::json;
namespace sdk { namespace sdk {
namespace { namespace {
std::mutex versionMutex;
std::string lastVersion;
json accountBlock(const std::string& uid, const std::string& token) { json accountBlock(const std::string& uid, const std::string& token) {
return json{{"uid", uid}, return json{{"uid", uid},
{"name", ""}, {"name", ""},
@@ -101,6 +107,11 @@ std::string gatewayBody(const std::string& version) {
} // namespace } // namespace
std::string lastClientVersion() {
std::lock_guard<std::mutex> lock(versionMutex);
return lastVersion;
}
void registerRoutes(http::Server& server) { void registerRoutes(http::Server& server) {
server.get("/query_dispatch", [](const http::Request&, http::Response& res) { server.get("/query_dispatch", [](const http::Request&, http::Response& res) {
res.text(dispatchBody()); res.text(dispatchBody());
@@ -108,6 +119,10 @@ void registerRoutes(http::Server& server) {
server.get("/query_gateway", [](const http::Request& req, http::Response& res) { server.get("/query_gateway", [](const http::Request& req, http::Response& res) {
std::string version = req.queryValue("version"); std::string version = req.queryValue("version");
{
std::lock_guard<std::mutex> lock(versionMutex);
lastVersion = version;
}
logging::info("dispatch", "query_gateway version={} seed={}", version, logging::info("dispatch", "query_gateway version={} seed={}", version,
req.queryValue("dispatch_seed")); req.queryValue("dispatch_seed"));
res.text(gatewayBody(version)); res.text(gatewayBody(version));
+4
View File
@@ -6,4 +6,8 @@ namespace sdk {
void registerRoutes(http::Server& server); 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 } // namespace sdk
+305
View File
@@ -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 // 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. // 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"); 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 // 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 sitting on the defeat screen or on a half-switched party.
client.sendEmpty(cmd::QuitBattleCsReq); client.sendEmpty(cmd::QuitBattleCsReq);
+136 -1
View File
@@ -18,6 +18,7 @@
#include "game/roster.h" #include "game/roster.h"
#include "game/scene.h" #include "game/scene.h"
#include "game/srtools.h" #include "game/srtools.h"
#include "game/tierce.h"
#include "net/packet.h" #include "net/packet.h"
#include "tests/harness.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.max_level_list.size() == 3, "one max level per mode");
check(rsp.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage, check(rsp.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage,
"the challenge history fits in one kcp message"); "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() { void testSceneRes() {
@@ -500,7 +522,6 @@ void testBattleBuild() {
request.casterEntityId = 1; request.casterEntityId = 1;
request.skillIndex = 0; request.skillIndex = 0;
request.monsterEntityIds = {monsterEntity}; request.monsterEntityIds = {monsterEntity};
request.allowSrToolsOverride = false;
proto::SceneBattleInfo info = game::battle::create(player, request); proto::SceneBattleInfo info = game::battle::create(player, request);
check(info.battle_id != 0, "the fight got an id"); check(info.battle_id != 0, "the fight got an id");
@@ -565,6 +586,115 @@ void testBattleBuild() {
check(!stillThere, "and can be switched off"); 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<proto::ChallengeTierceStageLineupInfo> 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 // 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. // model -- the loadout presets are the user's, not ours.
void testSrToolsRoundTrip() { void testSrToolsRoundTrip() {
@@ -672,6 +802,9 @@ void runGameTests() {
std::string root = util::findRootDir("config/config.json"); std::string root = util::findRootDir("config/config.json");
if (!root.empty()) util::setCurrentDir(root); if (!root.empty()) util::setCurrentDir(root);
core::Config::get().load("config/config.json"); 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::Tables::get().load(core::Config::get().paths.dataSources);
data::SceneRes::get().load(core::Config::get().paths.sceneRes); data::SceneRes::get().load(core::Config::get().paths.sceneRes);
data::SceneRes::get().loadAnchors(core::Config::get().paths.anchors); data::SceneRes::get().loadAnchors(core::Config::get().paths.anchors);
@@ -688,8 +821,10 @@ void runGameTests() {
testRosterProtos(); testRosterProtos();
testSceneBuild(); testSceneBuild();
testChallengeRun(); testChallengeRun();
testTierceRun();
testEveryScenePacketFits(); testEveryScenePacketFits();
testBattleBuild(); testBattleBuild();
testBattleSourceSplit();
testPropInteraction(); testPropInteraction();
testSrToolsRoundTrip(); testSrToolsRoundTrip();
} }