feat(game): add warp pulls

This commit is contained in:
capyb2222 committed 2026-09-12 19:37:15 -07:00
1 parent 0e2f2f71f7
commit f27cde756c
19 files changed
+8164 -23

No files matched your search

+3 -1
View File
@@ -44,7 +44,9 @@
"unlock_all_challenges": true, "unlock_all_challenges": true,
"main_character": 8008, "main_character": 8008,
"march_type": 1224, "march_type": 1224,
"skip_missions": [] "skip_missions": [],
"limited_warp_pools": [],
"standard_warp_featured": 1503
}, },
"paths": { "paths": {
"srtools_file": "data/freesr-data.json", "srtools_file": "data/freesr-data.json",
+6
View File
@@ -297,6 +297,12 @@ GetActivityScheduleConfigCsReq
GetActivityScheduleConfigScRsp GetActivityScheduleConfigScRsp
DoGachaCsReq DoGachaCsReq
DoGachaScRsp DoGachaScRsp
GetGachaCeilingCsReq
GetGachaCeilingScRsp
ExchangeGachaCeilingCsReq
ExchangeGachaCeilingScRsp
SetGachaDecideItemCsReq
SetGachaDecideItemScRsp
GetMailCsReq GetMailCsReq
GetMailScRsp GetMailScRsp
GetPhoneDataCsReq GetPhoneDataCsReq
+134
View File
@@ -0,0 +1,134 @@
[
{
"GachaID": 1001,
"GachaType": "Normal",
"SortID": 99,
"StartTime": "",
"EndTime": "",
"PrefabPath": "UI/Drawcard/GachaPanel/StandardGacha_1001.prefab",
"PoolName": {
"Hash": 12642828881931173103
},
"PoolDesc": {
"Hash": 1121704090967857799
},
"PoolDescFTC": {
"Hash": 6970163302865048869
},
"PoolLabelIcon": "SpriteOutput/DrawCardPic/GachaTabIcon/TabIcon_Standard.png",
"PoolLabelIconSelected": "SpriteOutput/DrawCardPic/GachaTabIcon/TabIcon_Standard_Selected.png",
"TypeTitle": {
"Hash": 10012362747660649297
}
},
{
"GachaID": 4001,
"GachaType": "Newbie",
"SortID": 1,
"StartTime": "",
"EndTime": "",
"PrefabPath": "UI/Drawcard/GachaPanel/NoviceGacha_4001.prefab",
"PoolName": {
"Hash": 16076008841484912183
},
"PoolDesc": {
"Hash": 13113094041792250269
},
"PoolDescFTC": {
"Hash": 6682982103413155220
},
"PoolLabelIcon": "SpriteOutput/DrawCardPic/GachaTabIcon/TabIcon_Novice.png",
"PoolLabelIconSelected": "SpriteOutput/DrawCardPic/GachaTabIcon/TabIcon_Novice_Selected.png",
"TypeTitle": {
"Hash": 12992035607736039415
}
},
{
"GachaID": 2001,
"GachaType": "AvatarUp",
"SortID": 2,
"StartTime": "",
"EndTime": "",
"PrefabPath": "UI/Drawcard/GachaPanelLimited/AvatarGacha_1102.prefab",
"PoolName": {
"Hash": 10466444443325460861
},
"PoolDesc": {
"Hash": 12575760539773716760
},
"PoolDescFTC": {
"Hash": 9461473220466468630
},
"PoolLabelIcon": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_1102.png",
"PoolLabelIconSelected": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_1102_Selected.png",
"TypeTitle": {
"Hash": 16335421618088345187
}
},
{
"GachaID": 3001,
"GachaType": "WeaponUp",
"SortID": 4,
"StartTime": "",
"EndTime": "",
"PrefabPath": "UI/Drawcard/GachaPanelLimited/LightConeGacha_23001.prefab",
"PoolName": {
"Hash": 10051276577110162368
},
"PoolDesc": {
"Hash": 13567216986870304529
},
"PoolDescFTC": {
"Hash": 12441093686588219336
},
"PoolLabelIcon": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_LightCone_23001.png",
"PoolLabelIconSelected": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_LightCone_23001_Selected.png",
"TypeTitle": {
"Hash": 689370920993170148
}
},
{
"GachaID": 2002,
"GachaType": "AvatarUp",
"SortID": 3,
"StartTime": "",
"EndTime": "",
"PrefabPath": "UI/Drawcard/GachaPanelLimited/AvatarGacha_1204.prefab",
"PoolName": {
"Hash": 9253952357381402356
},
"PoolDesc": {
"Hash": 12575760539773716760
},
"PoolDescFTC": {
"Hash": 9461473220466468630
},
"PoolLabelIcon": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_1204.png",
"PoolLabelIconSelected": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_1204_Selected.png",
"TypeTitle": {
"Hash": 16335421618088345187
}
},
{
"GachaID": 3002,
"GachaType": "WeaponUp",
"SortID": 5,
"StartTime": "",
"EndTime": "",
"PrefabPath": "UI/Drawcard/GachaPanelLimited/LightConeGacha_23010.prefab",
"PoolName": {
"Hash": 3268726668422334764
},
"PoolDesc": {
"Hash": 13567216986870304529
},
"PoolDescFTC": {
"Hash": 12441093686588219336
},
"PoolLabelIcon": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_LightCone_23010.png",
"PoolLabelIconSelected": "SpriteOutput/DrawCardPic/GachaTabIconLimited/TabIcon_LightCone_23010_Selected.png",
"TypeTitle": {
"Hash": 689370920993170148
}
}
]
+535
View File
@@ -0,0 +1,535 @@
[
{
"ID": 1,
"RewardItem": 300011,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 2,
"RewardItem": 1,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 3,
"RewardItem": 102,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 4,
"RewardItem": 300003,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 5,
"RewardItem": 300002,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 6,
"RewardItem": 200103,
"RewardIcon": ""
},
{
"ID": 7,
"RewardItem": 200104,
"RewardIcon": ""
},
{
"ID": 8,
"RewardItem": 1,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 9,
"RewardItem": 201,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 10,
"RewardItem": 200101,
"RewardIcon": ""
},
{
"ID": 11,
"RewardItem": 200102,
"RewardIcon": ""
},
{
"ID": 12,
"RewardItem": 200107,
"RewardIcon": ""
},
{
"ID": 13,
"RewardItem": 200108,
"RewardIcon": ""
},
{
"ID": 14,
"RewardItem": 200109,
"RewardIcon": ""
},
{
"ID": 15,
"RewardItem": 200110,
"RewardIcon": ""
},
{
"ID": 16,
"RewardItem": 200111,
"RewardIcon": ""
},
{
"ID": 17,
"RewardItem": 200112,
"RewardIcon": ""
},
{
"ID": 18,
"RewardItem": 200113,
"RewardIcon": ""
},
{
"ID": 19,
"RewardItem": 200114,
"RewardIcon": ""
},
{
"ID": 20,
"RewardItem": 1,
"RewardIcon": ""
},
{
"ID": 21,
"RewardItem": 236,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 22,
"RewardItem": 102,
"RewardIcon": ""
},
{
"ID": 23,
"RewardItem": 300011,
"RewardIcon": ""
},
{
"ID": 24,
"RewardItem": 300003,
"RewardIcon": ""
},
{
"ID": 25,
"RewardItem": 201,
"RewardIcon": ""
},
{
"ID": 26,
"RewardItem": 241,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 27,
"RewardItem": 213,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 28,
"RewardItem": 223,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 29,
"RewardItem": 235,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 30,
"RewardItem": 200115,
"RewardIcon": ""
},
{
"ID": 31,
"RewardItem": 200116,
"RewardIcon": ""
},
{
"ID": 32,
"RewardItem": 110101,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 33,
"RewardItem": 200117,
"RewardIcon": ""
},
{
"ID": 34,
"RewardItem": 200118,
"RewardIcon": ""
},
{
"ID": 35,
"RewardItem": 200119,
"RewardIcon": ""
},
{
"ID": 36,
"RewardItem": 200120,
"RewardIcon": ""
},
{
"ID": 37,
"RewardItem": 200121,
"RewardIcon": ""
},
{
"ID": 38,
"RewardItem": 200122,
"RewardIcon": ""
},
{
"ID": 39,
"RewardItem": 200123,
"RewardIcon": ""
},
{
"ID": 40,
"RewardItem": 200124,
"RewardIcon": ""
},
{
"ID": 41,
"RewardItem": 200125,
"RewardIcon": ""
},
{
"ID": 42,
"RewardItem": 200126,
"RewardIcon": ""
},
{
"ID": 43,
"RewardItem": 200127,
"RewardIcon": ""
},
{
"ID": 44,
"RewardItem": 200128,
"RewardIcon": ""
},
{
"ID": 45,
"RewardItem": 200129,
"RewardIcon": ""
},
{
"ID": 46,
"RewardItem": 200130,
"RewardIcon": ""
},
{
"ID": 47,
"RewardItem": 200131,
"RewardIcon": ""
},
{
"ID": 48,
"RewardItem": 200132,
"RewardIcon": ""
},
{
"ID": 49,
"RewardItem": 200133,
"RewardIcon": ""
},
{
"ID": 50,
"RewardItem": 200134,
"RewardIcon": ""
},
{
"ID": 51,
"RewardItem": 200135,
"RewardIcon": ""
},
{
"ID": 52,
"RewardItem": 200136,
"RewardIcon": ""
},
{
"ID": 53,
"RewardItem": 200137,
"RewardIcon": ""
},
{
"ID": 54,
"RewardItem": 200138,
"RewardIcon": ""
},
{
"ID": 55,
"RewardItem": 200139,
"RewardIcon": ""
},
{
"ID": 56,
"RewardItem": 200140,
"RewardIcon": ""
},
{
"ID": 57,
"RewardItem": 200141,
"RewardIcon": ""
},
{
"ID": 58,
"RewardItem": 200142,
"RewardIcon": ""
},
{
"ID": 59,
"RewardItem": 200143,
"RewardIcon": ""
},
{
"ID": 60,
"RewardItem": 200144,
"RewardIcon": ""
},
{
"ID": 61,
"RewardItem": 200145,
"RewardIcon": ""
},
{
"ID": 62,
"RewardItem": 200146,
"RewardIcon": ""
},
{
"ID": 63,
"RewardItem": 200147,
"RewardIcon": ""
},
{
"ID": 64,
"RewardItem": 200148,
"RewardIcon": ""
},
{
"ID": 65,
"RewardItem": 200149,
"RewardIcon": ""
},
{
"ID": 66,
"RewardItem": 200150,
"RewardIcon": ""
},
{
"ID": 67,
"RewardItem": 200151,
"RewardIcon": ""
},
{
"ID": 68,
"RewardItem": 200152,
"RewardIcon": ""
},
{
"ID": 69,
"RewardItem": 200153,
"RewardIcon": ""
},
{
"ID": 70,
"RewardItem": 200154,
"RewardIcon": ""
},
{
"ID": 71,
"RewardItem": 200155,
"RewardIcon": ""
},
{
"ID": 72,
"RewardItem": 200156,
"RewardIcon": ""
},
{
"ID": 73,
"RewardItem": 200157,
"RewardIcon": ""
},
{
"ID": 74,
"RewardItem": 200158,
"RewardIcon": ""
},
{
"ID": 75,
"RewardItem": 200159,
"RewardIcon": ""
},
{
"ID": 76,
"RewardItem": 200160,
"RewardIcon": ""
},
{
"ID": 77,
"RewardItem": 200161,
"RewardIcon": ""
},
{
"ID": 78,
"RewardItem": 200162,
"RewardIcon": ""
},
{
"ID": 501,
"RewardItem": 238,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 502,
"RewardItem": 237,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 503,
"RewardItem": 300012,
"RewardIcon": ""
},
{
"ID": 504,
"RewardItem": 283,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 505,
"RewardItem": 300014,
"RewardIcon": ""
},
{
"ID": 1001,
"RewardItem": 21052,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1002,
"RewardItem": 21028,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1003,
"RewardItem": 21029,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1004,
"RewardItem": 21030,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1005,
"RewardItem": 21031,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1006,
"RewardItem": 21032,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1007,
"RewardItem": 21033,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1008,
"RewardItem": 21034,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1009,
"RewardItem": 21057,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1010,
"RewardItem": 21055,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1011,
"RewardItem": 21061,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1012,
"RewardItem": 21053,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1013,
"RewardItem": 21062,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1014,
"RewardItem": 21056,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1015,
"RewardItem": 21058,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1016,
"RewardItem": 21060,
"RewardIcon": "",
"NumShow": true
},
{
"ID": 1017,
"RewardItem": 21065,
"RewardIcon": "",
"NumShow": true
}
]
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
[
{
"GachaTypeID": "Normal",
"ItemCosume": 101,
"ItemPrice": 160,
"DiamondID": 1,
"GachaBar": "StandardGachaPage",
"BuyPos": {
"ShopID": 1000,
"ShopGoodID": 1000001
}
},
{
"GachaTypeID": "Newbie",
"ItemCosume": 101,
"ItemPrice": 160,
"DiamondID": 1,
"GachaBar": "StandardGachaPage",
"BuyPos": {
"ShopID": 1000,
"ShopGoodID": 1000001
}
},
{
"GachaTypeID": "AvatarUp",
"UpPropability": 50,
"ItemCosume": 102,
"ItemPrice": 160,
"DiamondID": 1,
"GachaBar": "CharacterGachaPage",
"BuyPos": {
"ShopID": 1000,
"ShopGoodID": 1000002
}
},
{
"GachaTypeID": "WeaponUp",
"UpPropability": 75,
"ItemCosume": 102,
"ItemPrice": 160,
"DiamondID": 1,
"GachaBar": "LightconeGachaPage",
"BuyPos": {
"ShopID": 1000,
"ShopGoodID": 1000002
}
},
{
"GachaTypeID": "CollaborationAvatarUp",
"UpPropability": 50,
"ItemCosume": 102,
"ItemPrice": 160,
"DiamondID": 1,
"GachaBar": "CharacterGachaPage",
"BuyPos": {
"ShopID": 1000,
"ShopGoodID": 1000002
}
},
{
"GachaTypeID": "CollaborationWeaponUp",
"UpPropability": 75,
"ItemCosume": 102,
"ItemPrice": 160,
"DiamondID": 1,
"GachaBar": "LightconeGachaPage",
"BuyPos": {
"ShopID": 1000,
"ShopGoodID": 1000002
}
}
]
+19
View File
@@ -105,6 +105,25 @@ bool Config::load(const std::string& path) {
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); pick(*it, "skip_missions", gameplay.skipMissions);
// Each entry is a pool id, or {"id": pool, "featured": 5* id}.
if (auto pools = it->find("limited_warp_pools"); pools != it->end() && pools->is_array()) {
gameplay.limitedWarpPools.clear();
for (const json& entry : *pools) {
WarpBanner banner;
if (entry.is_number_unsigned()) {
banner.id = entry.get<uint32_t>();
} else if (entry.is_object()) {
pick(entry, "id", banner.id);
pick(entry, "featured", banner.featured);
}
if (banner.id == 0) {
logging::warn("config", "a limited_warp_pools entry has no pool id");
continue;
}
gameplay.limitedWarpPools.push_back(banner);
}
}
pick(*it, "standard_warp_featured", gameplay.standardWarpFeatured);
} }
return true; return true;
} }
+12
View File
@@ -58,6 +58,11 @@ struct PathsConfig {
std::string resources = "resources"; std::string resources = "resources";
}; };
struct WarpBanner {
uint32_t id = 0;
uint32_t featured = 0; // 0 keeps the pool's own
};
struct GameplayConfig { struct GameplayConfig {
// auto = a srtools build replaces calyx fights and nothing else; srtools = it // auto = a srtools build replaces calyx fights and nothing else; srtools = it
// replaces every fight; stage = never, always use the stage the client asked for. // replaces every fight; stage = never, always use the stage the client asked for.
@@ -74,6 +79,13 @@ struct GameplayConfig {
// loading screen when entering at login, a fade that rolls straight back otherwise. // loading screen when entering at login, a fade that rolls straight back otherwise.
// Main and sub mission ids both go here. // Main and sub mission ids both go here.
std::vector<uint32_t> skipMissions; std::vector<uint32_t> skipMissions;
// Limited warp pools offered next to the standard one, each optionally featuring another
// 5*. The beta clients list 2001, 2002, 3001 and 3002 but ship no banner art for any of
// them, and one pool without art turns the whole warp page black, so leave it empty there.
std::vector<WarpBanner> limitedWarpPools;
// A 5* character Stellar Warp features, 50/50 and guarantee included, standing in for
// the current banner the beta clients have no art for. 0 keeps it the standard pool.
uint32_t standardWarpFeatured = 0;
bool missionSkipped(uint32_t id) const { bool missionSkipped(uint32_t id) const {
return std::find(skipMissions.begin(), skipMissions.end(), id) != skipMissions.end(); return std::find(skipMissions.begin(), skipMissions.end(), id) != skipMissions.end();
+106 -2
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cstdlib> #include <cstdlib>
#include <string_view>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -77,6 +78,35 @@ std::vector<uint32_t> u32List(const json& j, const char* key) {
return out; return out;
} }
// "CombatPowerAvatarRarityType5", "CombatPowerLightconeRarity4": the stars are the last digit.
uint32_t rarityOf(const json& row) {
auto it = row.find("Rarity");
if (it == row.end()) return 0;
if (it->is_number()) return static_cast<uint32_t>(numberOf(*it));
if (!it->is_string()) return 0;
const std::string& name = it->get_ref<const std::string&>();
char last = name.empty() ? '\0' : name.back();
return (last >= '1' && last <= '9') ? static_cast<uint32_t>(last - '0') : 0;
}
// ".../AvatarGacha_1102.prefab" -> 1102. The Sub* prefabs belong to a grouped pool and
// give 0.
uint32_t featuredFromPrefab(const std::string& path, std::string_view stem) {
size_t slash = path.find_last_of('/');
std::string_view file(path);
if (slash != std::string::npos) file.remove_prefix(slash + 1);
constexpr std::string_view suffix = ".prefab";
if (file.size() <= stem.size() + suffix.size() || file.substr(0, stem.size()) != stem ||
file.substr(file.size() - suffix.size()) != suffix) {
return 0;
}
std::string_view digits = file.substr(stem.size(), file.size() - stem.size() - suffix.size());
if (digits.size() > 9 || digits.find_first_not_of("0123456789") != std::string_view::npos) {
return 0;
}
return static_cast<uint32_t>(std::strtoul(std::string(digits).c_str(), nullptr, 10));
}
// AvatarSkillTreeConfig.AnchorType is "Point07"; the client wants the number. // AvatarSkillTreeConfig.AnchorType is "Point07"; the client wants the number.
uint32_t anchorNumber(const std::string& anchor) { uint32_t anchorNumber(const std::string& anchor) {
size_t i = 0; size_t i = 0;
@@ -180,6 +210,23 @@ const AvatarInfo* Tables::avatar(uint32_t id) const {
return it == avatars_.end() ? nullptr : &it->second; return it == avatars_.end() ? nullptr : &it->second;
} }
const LightconeInfo* Tables::lightcone(uint32_t id) const {
auto it = lightcones_.find(id);
return it == lightcones_.end() ? nullptr : &it->second;
}
uint32_t Tables::gachaUpChance(GachaType type) const {
switch (type) {
case GachaType::AvatarUp:
return avatarUpChance_;
case GachaType::WeaponUp:
return weaponUpChance_;
case GachaType::Normal:
break;
}
return 0;
}
const StageInfo* Tables::stage(uint32_t id) const { const StageInfo* Tables::stage(uint32_t id) const {
auto it = stages_.find(id); auto it = stages_.find(id);
return it == stages_.end() ? nullptr : &it->second; return it == stages_.end() ? nullptr : &it->second;
@@ -331,6 +378,7 @@ bool Tables::load(const std::vector<std::string>& sources) {
uint64_t started = util::nowMs(); uint64_t started = util::nowMs();
size_t used = 0; size_t used = 0;
bool standardGachaFound = false; bool standardGachaFound = false;
std::unordered_map<std::string, uint32_t> upChances;
for (const std::string& dir : sources) { for (const std::string& dir : sources) {
json doc; json doc;
@@ -356,6 +404,7 @@ bool Tables::load(const std::vector<std::string>& sources) {
info.baseType = str(*row, "AvatarBaseType"); info.baseType = str(*row, "AvatarBaseType");
info.maxPromotion = u32(*row, "MaxPromotion", 6); info.maxPromotion = u32(*row, "MaxPromotion", 6);
info.maxRank = u32(*row, "MaxRank", 6); info.maxRank = u32(*row, "MaxRank", 6);
info.rarity = rarityOf(*row);
// SPNeed is in whole points; SpBarInfo counts hundredths. // SPNeed is in whole points; SpBarInfo counts hundredths.
uint32_t spNeed = u32(*row, "SPNeed"); uint32_t spNeed = u32(*row, "SPNeed");
info.spNeed = spNeed != 0 ? spNeed * 100 : 10000; info.spNeed = spNeed != 0 ? spNeed * 100 : 10000;
@@ -503,12 +552,48 @@ bool Tables::load(const std::vector<std::string>& sources) {
farmStages_.insert(stageId); farmStages_.insert(stageId);
} }
for (const json* row : table("GachaBasicInfo.json")) { for (const json* row : table("GachaTypeBasicInfo.json")) {
if (standardGachaFound || str(*row, "GachaType") != "Normal") continue; std::string type = str(*row, "GachaTypeID");
if (!type.empty()) upChances.emplace(type, u32(*row, "UpPropability"));
}
// The client throws on a pool its own table lacks, and the beta table is a six-row
// stub, so here the last source with the table wins instead of the first.
const std::vector<const json*>& gachaRows = table("GachaBasicInfo.json");
if (!gachaRows.empty()) {
gachaPools_.clear();
standardGachaFound = false;
}
for (const json* row : gachaRows) {
uint32_t id = u32(*row, "GachaID"); uint32_t id = u32(*row, "GachaID");
if (id == 0) continue; if (id == 0) continue;
std::string type = str(*row, "GachaType");
GachaPool pool;
pool.id = id;
if (type == "Normal") {
if (standardGachaFound && id != standardGacha_.gachaId) continue;
standardGacha_.gachaId = id; standardGacha_.gachaId = id;
standardGachaFound = true; standardGachaFound = true;
} else if (type == "AvatarUp") {
pool.type = GachaType::AvatarUp;
pool.featured = featuredFromPrefab(str(*row, "PrefabPath"), "AvatarGacha_");
} else if (type == "WeaponUp") {
pool.type = GachaType::WeaponUp;
pool.featured = featuredFromPrefab(str(*row, "PrefabPath"), "LightConeGacha_");
} else {
continue;
}
if (pool.type != GachaType::Normal && pool.featured == 0) continue;
gachaPools_.push_back(pool);
}
for (const json* row : table("EquipmentConfig.json")) {
uint32_t id = u32(*row, "EquipmentID");
if (id == 0 || lightcones_.count(id) != 0) continue;
lightcones_.emplace(id, LightconeInfo{id, rarityOf(*row)});
}
for (const json* row : table("BattlePassReward.json")) {
uint32_t item = u32(*row, "RewardItem");
if (item != 0) battlePassRewards_.insert(item);
} }
for (const json* row : table("GachaCeiling.json")) { for (const json* row : table("GachaCeiling.json")) {
if (!standardGacha_.ceilingAvatars.empty() || str(*row, "GachaType") != "Normal") { if (!standardGacha_.ceilingAvatars.empty() || str(*row, "GachaType") != "Normal") {
@@ -744,6 +829,24 @@ bool Tables::load(const std::vector<std::string>& sources) {
sortById(challenges_); sortById(challenges_);
sortById(challengeGroups_); sortById(challengeGroups_);
sortById(peakGroups_); sortById(peakGroups_);
sortById(gachaPools_);
// The defaults are GachaTypeBasicInfo's own numbers, for when the table is missing.
if (auto it = upChances.find("AvatarUp"); it != upChances.end()) avatarUpChance_ = it->second;
if (auto it = upChances.find("WeaponUp"); it != upChances.end()) weaponUpChance_ = it->second;
for (GachaPool& pool : gachaPools_) pool.upChance = gachaUpChance(pool.type);
// A featured 5* the tables do not have cannot be drawn.
std::erase_if(gachaPools_, [&](const GachaPool& pool) {
if (pool.type == GachaType::AvatarUp) {
const AvatarInfo* info = avatar(pool.featured);
return info == nullptr || info->rarity != 5;
}
if (pool.type == GachaType::WeaponUp) {
const LightconeInfo* info = lightcone(pool.featured);
return info == nullptr || info->rarity != 5;
}
return false;
});
// Each fight learns its season, and plants its one monster over the arena's marker. // Each fight learns its season, and plants its one monster over the arena's marker.
for (const PeakGroupInfo& group : peakGroups_) { for (const PeakGroupInfo& group : peakGroups_) {
@@ -779,6 +882,7 @@ bool Tables::load(const std::vector<std::string>& sources) {
entrances_.size(), planeEvents_.size(), used, util::nowMs() - started); entrances_.size(), planeEvents_.size(), used, util::nowMs() - started);
logging::info("data", "{} challenge floors in {} seasons, {} of them with a third node", logging::info("data", "{} challenge floors in {} seasons, {} of them with a third node",
challenges_.size(), challengeGroups_.size(), challengeTierces_.size()); challenges_.size(), challengeGroups_.size(), challengeTierces_.size());
logging::info("data", "{} lightcones, {} warp pools", lightcones_.size(), gachaPools_.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());
+30
View File
@@ -18,6 +18,7 @@ struct AvatarInfo {
uint32_t spNeed = 10000; // hundredths, as the SpBarInfo wants it uint32_t spNeed = 10000; // hundredths, as the SpBarInfo wants it
uint32_t maxPromotion = 6; uint32_t maxPromotion = 6;
uint32_t maxRank = 6; uint32_t maxRank = 6;
uint32_t rarity = 0;
uint32_t enhancedId = 0; uint32_t enhancedId = 0;
uint32_t attackBuffId = 0; uint32_t attackBuffId = 0;
// AvatarDefaultMazeBuff; falls back to id * 100 + 1 when the row is missing. // AvatarDefaultMazeBuff; falls back to id * 100 + 1 when the row is missing.
@@ -25,6 +26,21 @@ struct AvatarInfo {
uint32_t techniqueSkillIndex = 2; uint32_t techniqueSkillIndex = 2;
}; };
struct LightconeInfo {
uint32_t id = 0;
uint32_t rarity = 0;
};
enum class GachaType { Normal, AvatarUp, WeaponUp };
// A warp pool. A limited one is named after its featured 5* in its prefab.
struct GachaPool {
uint32_t id = 0;
GachaType type = GachaType::Normal;
uint32_t featured = 0;
uint32_t upChance = 0; // percent
};
struct SkillTreePoint { struct SkillTreePoint {
uint32_t pointId = 0; uint32_t pointId = 0;
uint32_t avatarId = 0; uint32_t avatarId = 0;
@@ -247,6 +263,15 @@ public:
uint32_t farmElementStage(uint32_t idOrStage, uint32_t worldLevel) const; uint32_t farmElementStage(uint32_t idOrStage, uint32_t worldLevel) const;
const StandardGacha& standardGacha() const { return standardGacha_; } const StandardGacha& standardGacha() const { return standardGacha_; }
// The standard pool, then the limited ones in id order.
const std::vector<GachaPool>& gachaPools() const { return gachaPools_; }
// GachaTypeBasicInfo.UpPropability, in percent; 0 for the standard pool.
uint32_t gachaUpChance(GachaType type) const;
const LightconeInfo* lightcone(uint32_t id) const;
const std::unordered_map<uint32_t, LightconeInfo>& lightcones() const { return lightcones_; }
// BattlePassReward: paid out by the pass, never by a warp.
bool battlePassReward(uint32_t itemId) const { return battlePassRewards_.count(itemId) != 0; }
// AvatarMazeBuff rows named ADV_GlobalSkill_Maze*: Castorice and Silver Wolf. // AvatarMazeBuff rows named ADV_GlobalSkill_Maze*: Castorice and Silver Wolf.
const std::vector<uint32_t>& globalMazeBuffs() const { return globalMazeBuffs_; } const std::vector<uint32_t>& globalMazeBuffs() const { return globalMazeBuffs_; }
@@ -300,6 +325,11 @@ private:
std::unordered_map<uint64_t, uint32_t> farmElements_; // id * 100 + worldLevel -> stage std::unordered_map<uint64_t, uint32_t> farmElements_; // id * 100 + worldLevel -> stage
std::unordered_set<uint32_t> farmStages_; std::unordered_set<uint32_t> farmStages_;
StandardGacha standardGacha_; StandardGacha standardGacha_;
std::vector<GachaPool> gachaPools_;
uint32_t avatarUpChance_ = 50;
uint32_t weaponUpChance_ = 75;
std::unordered_map<uint32_t, LightconeInfo> lightcones_;
std::unordered_set<uint32_t> battlePassRewards_;
std::vector<uint32_t> globalMazeBuffs_; std::vector<uint32_t> globalMazeBuffs_;
std::vector<uint32_t> mainMissions_; std::vector<uint32_t> mainMissions_;
std::vector<uint32_t> tutorials_; std::vector<uint32_t> tutorials_;
+244
View File
@@ -0,0 +1,244 @@
#include "game/gacha.h"
#include <algorithm>
#include "core/util.h"
namespace game {
namespace {
// Stellar Warp's 5* light cones; no table lists them.
constexpr uint32_t kStandardLightcones[] = {23000, 23002, 23003, 23004, 23005, 23012, 23013};
constexpr uint32_t kUndyingEmbers = 251;
constexpr uint32_t kUndyingStarlight = 252;
constexpr uint32_t kEidolonItemOffset = 10000;
constexpr uint32_t kFourStarPity = 10;
struct Rates {
uint32_t hardPity;
uint32_t softPity;
double base;
double step;
double fourStar;
};
// 0.6%, +6% a pull from 74, certain at 90. Light cones: 0.8%, +7% from 66, certain at 80.
constexpr Rates kCharacterRates{90, 74, 0.006, 0.06, 0.051};
constexpr Rates kLightconeRates{80, 66, 0.008, 0.07, 0.066};
double fiveStarChance(const Rates& rates, uint32_t pullNumber) {
if (pullNumber >= rates.hardPity) return 1.0;
if (pullNumber < rates.softPity) return rates.base;
return std::min(1.0, rates.base + rates.step * (pullNumber - rates.softPity + 1));
}
uint32_t pick(const std::vector<uint32_t>& ids, const GachaRoll& roll) {
size_t index = static_cast<size_t>(roll() * static_cast<double>(ids.size()));
return ids[std::min(index, ids.size() - 1)];
}
GachaPull pullOne(const data::GachaPool& pool, const GachaItemPools& items, GachaPity& pity,
const GachaRoll& roll) {
const Rates& rates = pool.type == data::GachaType::WeaponUp ? kLightconeRates : kCharacterRates;
++pity.total;
GachaPull pull;
if (roll() < fiveStarChance(rates, pity.sinceFive + 1)) {
pull.rarity = 5;
pity.sinceFive = 0;
// A 5* does not use up the 4* guarantee; it lands on the next pull instead.
++pity.sinceFour;
if (pool.type == data::GachaType::Normal) {
pull.avatar = roll() < 0.5;
pull.itemId = pick(pull.avatar ? items.fiveStarAvatars : items.fiveStarLightcones, roll);
return pull;
}
pull.avatar = pool.type == data::GachaType::AvatarUp;
std::vector<uint32_t> others;
for (uint32_t id : pull.avatar ? items.fiveStarAvatars : items.fiveStarLightcones) {
if (id != pool.featured) others.push_back(id);
}
pull.guaranteed = pity.guaranteed;
pull.featured = pity.guaranteed || others.empty() || roll() * 100.0 < pool.upChance;
pity.guaranteed = !pull.featured;
pull.itemId = pull.featured ? pool.featured : pick(others, roll);
return pull;
}
++pity.sinceFive;
if (pity.sinceFour + 1 >= kFourStarPity || roll() < rates.fourStar) {
pull.rarity = 4;
pity.sinceFour = 0;
pull.avatar = roll() < 0.5;
pull.itemId = pick(pull.avatar ? items.fourStarAvatars : items.fourStarLightcones, roll);
return pull;
}
++pity.sinceFour;
pull.itemId = pick(items.threeStarLightcones, roll);
return pull;
}
} // namespace
GachaPity& GachaProgress::forType(data::GachaType type) {
switch (type) {
case data::GachaType::AvatarUp:
return character;
case data::GachaType::WeaponUp:
return lightcone;
case data::GachaType::Normal:
break;
}
return standard;
}
bool GachaItemPools::complete() const {
return !fiveStarAvatars.empty() && !fiveStarLightcones.empty() && !fourStarAvatars.empty() &&
!fourStarLightcones.empty() && !threeStarLightcones.empty();
}
GachaItemPools gachaItemPools(const data::Tables& tables) {
GachaItemPools items;
items.fiveStarAvatars = tables.standardGacha().ceilingAvatars;
for (uint32_t id : kStandardLightcones) {
const data::LightconeInfo* info = tables.lightcone(id);
if (info != nullptr && info->rarity == 5) items.fiveStarLightcones.push_back(id);
}
for (const auto& [id, avatar] : tables.avatars()) {
// Playable ids, with March's second form and the Trailblazer's paths folded away.
if (avatar.rarity != 4 || id < 1000 || id >= 2000 || tables.baseAvatarId(id) != id) continue;
items.fourStarAvatars.push_back(id);
}
for (const auto& [id, lightcone] : tables.lightcones()) {
// 22xxx are event rewards.
if (id >= 22000 || tables.battlePassReward(id)) continue;
if (lightcone.rarity == 4) items.fourStarLightcones.push_back(id);
if (lightcone.rarity == 3) items.threeStarLightcones.push_back(id);
}
std::sort(items.fourStarAvatars.begin(), items.fourStarAvatars.end());
std::sort(items.fourStarLightcones.begin(), items.fourStarLightcones.end());
std::sort(items.threeStarLightcones.begin(), items.threeStarLightcones.end());
return items;
}
double gachaRoll() { return static_cast<double>(util::randomU64() >> 11) * 0x1.0p-53; }
namespace {
bool featurable(const data::Tables& tables, data::GachaType type, uint32_t id) {
if (type == data::GachaType::AvatarUp) {
const data::AvatarInfo* avatar = tables.avatar(id);
return avatar != nullptr && avatar->rarity == 5;
}
if (type == data::GachaType::WeaponUp) {
const data::LightconeInfo* lightcone = tables.lightcone(id);
return lightcone != nullptr && lightcone->rarity == 5;
}
return false;
}
} // namespace
std::vector<data::GachaPool> offeredGachaPools(const data::Tables& tables,
const std::vector<core::WarpBanner>& limited) {
const std::vector<data::GachaPool>& pools = tables.gachaPools();
auto find = [&pools](uint32_t id) -> const data::GachaPool* {
for (const data::GachaPool& pool : pools) {
if (pool.id == id) return &pool;
}
return nullptr;
};
std::vector<data::GachaPool> offered;
const data::GachaPool* standard = find(tables.standardGacha().gachaId);
if (standard != nullptr && standard->type == data::GachaType::Normal) offered.push_back(*standard);
for (const core::WarpBanner& banner : limited) {
const data::GachaPool* pool = find(banner.id);
if (pool == nullptr || pool->type == data::GachaType::Normal) continue;
bool taken = std::any_of(offered.begin(), offered.end(),
[pool](const data::GachaPool& p) { return p.id == pool->id; });
if (taken) continue;
data::GachaPool entry = *pool;
if (banner.featured != 0 && featurable(tables, entry.type, banner.featured)) {
entry.featured = banner.featured;
}
offered.push_back(std::move(entry));
}
return offered;
}
std::optional<data::GachaPool> offeredGachaPool(const data::Tables& tables,
const std::vector<core::WarpBanner>& limited,
uint32_t gachaId) {
for (data::GachaPool& pool : offeredGachaPools(tables, limited)) {
if (pool.id == gachaId) return std::move(pool);
}
return std::nullopt;
}
data::GachaPool effectiveGachaPool(const data::GachaPool& pool, const data::Tables& tables,
uint32_t standardFeatured) {
if (pool.type != data::GachaType::Normal || standardFeatured == 0) return pool;
if (!featurable(tables, data::GachaType::AvatarUp, standardFeatured)) return pool;
data::GachaPool banner = pool;
banner.type = data::GachaType::AvatarUp;
banner.featured = standardFeatured;
banner.upChance = tables.gachaUpChance(data::GachaType::AvatarUp);
return banner;
}
std::vector<GachaPull> pullGacha(const data::GachaPool& pool, const GachaItemPools& items,
GachaPity& pity, uint32_t count, const GachaRoll& roll) {
std::vector<GachaPull> pulls;
pulls.reserve(count);
for (uint32_t i = 0; i < count; ++i) pulls.push_back(pullOne(pool, items, pity, roll));
return pulls;
}
proto::GachaItem gachaResult(const GachaPull& pull, const SrToolsData& roster) {
proto::GachaItem result;
proto::Item& item = result.gacha_item.emplace();
item.item_id = pull.itemId;
item.num = 1;
item.level = 1;
item.rank = 1;
// Both lists go out even when empty.
proto::ItemList& transfer = result.transfer_item_list.emplace();
proto::ItemList& tokens = result.token_item.emplace();
auto token = [&tokens](uint32_t id, uint32_t num) {
proto::Item entry;
entry.item_id = id;
entry.num = num;
tokens.item_list.push_back(entry);
};
if (!pull.avatar) {
if (pull.rarity == 3) {
token(kUndyingEmbers, 20);
} else {
token(kUndyingStarlight, pull.rarity == 5 ? 40 : 8);
}
return result;
}
auto owned = roster.avatars.find(pull.itemId);
if (owned == roster.avatars.end()) {
result.is_new = true;
return result;
}
bool fiveStar = pull.rarity == 5;
if (owned->second.rank >= 6) {
token(kUndyingStarlight, fiveStar ? 100 : 20);
return result;
}
token(kUndyingStarlight, fiveStar ? 40 : 8);
proto::Item eidolon;
eidolon.item_id = pull.itemId + kEidolonItemOffset;
eidolon.num = 1;
transfer.item_list.push_back(eidolon);
return result;
}
} // namespace game
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include "core/config.h"
#include "data/excel.h"
#include "game/srtools.h"
#include "proto/gen/protos.h"
namespace game {
struct GachaPity {
uint32_t sinceFive = 0;
uint32_t sinceFour = 0;
bool guaranteed = false; // the next 5* is the featured one
uint32_t total = 0;
};
// Every limited pool of one kind shares its counter.
struct GachaProgress {
GachaPity standard;
GachaPity character;
GachaPity lightcone;
GachaPity& forType(data::GachaType type);
};
struct GachaItemPools {
std::vector<uint32_t> fiveStarAvatars;
std::vector<uint32_t> fiveStarLightcones;
std::vector<uint32_t> fourStarAvatars;
std::vector<uint32_t> fourStarLightcones;
std::vector<uint32_t> threeStarLightcones;
bool complete() const;
};
GachaItemPools gachaItemPools(const data::Tables& tables);
struct GachaPull {
uint32_t itemId = 0;
uint32_t rarity = 3;
bool avatar = false;
bool featured = false; // a limited 5* that was the featured one
bool guaranteed = false; // and was owed it by an earlier lost 50/50
};
// Uniform in [0, 1); injectable so the rates can be tested.
using GachaRoll = std::function<double()>;
double gachaRoll();
// The standard pool, then each configured limited pool the tables have, featuring what the
// config names. An override that is not a 5* of the pool's kind is ignored.
std::vector<data::GachaPool> offeredGachaPools(const data::Tables& tables,
const std::vector<core::WarpBanner>& limited);
std::optional<data::GachaPool> offeredGachaPool(const data::Tables& tables,
const std::vector<core::WarpBanner>& limited,
uint32_t gachaId);
// The standard pool as a character banner featuring `standardFeatured`. Anything but a 5*
// character leaves the pool as it is, and so does any limited pool.
data::GachaPool effectiveGachaPool(const data::GachaPool& pool, const data::Tables& tables,
uint32_t standardFeatured);
// `items` must be complete().
std::vector<GachaPull> pullGacha(const data::GachaPool& pool, const GachaItemPools& items,
GachaPity& pity, uint32_t count, const GachaRoll& roll);
// The result card. Nothing is granted: the srtools build stays the roster.
proto::GachaItem gachaResult(const GachaPull& pull, const SrToolsData& roster);
} // namespace game
+143
View File
@@ -0,0 +1,143 @@
#include <algorithm>
#include <cstdint>
#include <optional>
#include <vector>
#include "core/config.h"
#include "core/logger.h"
#include "core/util.h"
#include "data/excel.h"
#include "game/gacha.h"
#include "game/handlers.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 {
constexpr int64_t kLimitedEndTime = 4102416000; // 2100-01-01
// Character pools carry this in item_detail_list.
constexpr uint32_t kAvatarPoolDetail = 11;
// Always claimed: nothing is granted, so the 300-pull pick has nothing to pay out.
proto::GachaCeiling standardCeiling(const Player* player) {
const data::StandardGacha& standard = data::Tables::get().standardGacha();
proto::GachaCeiling ceiling;
ceiling.is_claimed = true;
uint32_t pulls = player != nullptr ? player->gacha().standard.total : 0;
ceiling.ceiling_num = std::min(pulls, standard.ceilingNum);
for (uint32_t avatarId : standard.ceilingAvatars) {
proto::GachaCeilingAvatar avatar;
avatar.avatar_id = avatarId;
ceiling.avatar_list.push_back(avatar);
}
return ceiling;
}
void onGetGachaInfo(net::Session& session, const proto::GetGachaInfoCsReq&) {
proto::GetGachaInfoScRsp rsp;
rsp.retcode = 0;
rsp.gacha_random = util::randomRange(1000, 1999);
const std::vector<core::WarpBanner>& limited = core::Config::get().gameplay.limitedWarpPools;
for (const data::GachaPool& pool : offeredGachaPools(data::Tables::get(), limited)) {
proto::GachaInfo& info = rsp.gacha_info_list.emplace_back();
info.gacha_id = pool.id;
if (pool.type == data::GachaType::Normal) {
info.gacha_ceiling.emplace() = standardCeiling(session.player());
continue;
}
info.end_time = kLimitedEndTime;
info.prize_item_list.push_back(pool.featured);
if (pool.type == data::GachaType::AvatarUp) info.item_detail_list.push_back(kAvatarPoolDetail);
}
session.send(cmd::GetGachaInfoScRsp, rsp);
}
void onDoGacha(net::Session& session, const proto::DoGachaCsReq& req) {
proto::DoGachaScRsp rsp;
rsp.gacha_id = req.gacha_id;
rsp.gacha_num = req.gacha_num;
Player* player = session.player();
const data::Tables& tables = data::Tables::get();
std::optional<data::GachaPool> pool =
offeredGachaPool(tables, core::Config::get().gameplay.limitedWarpPools, req.gacha_id);
if (player == nullptr) {
rsp.retcode = fail(proto::Retcode::RET_FAIL);
} else if (!pool) {
rsp.retcode = fail(proto::Retcode::RET_GACHA_ID_NOT_EXIST);
} else if (req.gacha_num != 1 && req.gacha_num != 10) {
rsp.retcode = fail(proto::Retcode::RET_GACHA_NUM_INVALID);
} else {
GachaItemPools items = gachaItemPools(tables);
if (!items.complete()) {
logging::warn("game", "warp {} has an empty item pool, check the tables", pool->id);
rsp.retcode = fail(proto::Retcode::RET_GACHA_NOT_SUPPORT);
} else {
uint32_t featured = core::Config::get().gameplay.standardWarpFeatured;
data::GachaPool banner = effectiveGachaPool(*pool, tables, featured);
if (pool->type == data::GachaType::Normal && featured != 0 && banner.featured == 0) {
logging::warn("game", "standard_warp_featured {} is not a 5* character", featured);
}
Roster roster = player->roster();
GachaPity& pity = player->gacha().forType(banner.type);
uint32_t fiveStars = 0;
for (const GachaPull& pull : pullGacha(banner, items, pity, req.gacha_num, gachaRoll)) {
if (pull.rarity == 5) {
++fiveStars;
const char* how = banner.type == data::GachaType::Normal ? "standard"
: pull.guaranteed ? "guaranteed"
: pull.featured ? "won the 50/50"
: "lost the 50/50";
logging::info("game", "warp {} 5*: {} ({})", banner.id, pull.itemId, how);
}
rsp.gacha_item_list.push_back(gachaResult(pull, roster.data()));
}
player->saveNow();
logging::info("game", "warp {} x{}: {} 5*, {} pulls since the last", banner.id,
req.gacha_num, fiveStars, pity.sinceFive);
}
}
session.send(cmd::DoGachaScRsp, rsp);
}
void onGetGachaCeiling(net::Session& session, const proto::GetGachaCeilingCsReq& req) {
proto::GetGachaCeilingScRsp rsp;
rsp.retcode = 0;
rsp.gacha_type = req.DDMCNOJFGON;
rsp.gacha_ceiling.emplace() = standardCeiling(session.player());
session.send(cmd::GetGachaCeilingScRsp, rsp);
}
void onExchangeGachaCeiling(net::Session& session, const proto::ExchangeGachaCeilingCsReq& req) {
proto::ExchangeGachaCeilingScRsp rsp;
rsp.retcode = fail(proto::Retcode::RET_GACHA_CEILING_CLOSE);
rsp.gacha_type = req.gacha_type;
rsp.avatar_id = req.avatar_id;
rsp.gacha_ceiling.emplace() = standardCeiling(session.player());
rsp.transfer_item_list.emplace();
session.send(cmd::ExchangeGachaCeilingScRsp, rsp);
}
// A lost 50/50 always draws from the standard seven, so a custom pick is refused.
void onSetGachaDecideItem(net::Session& session, const proto::SetGachaDecideItemCsReq&) {
proto::SetGachaDecideItemScRsp rsp;
rsp.retcode = fail(proto::Retcode::RET_GACHA_NOT_SUPPORT);
rsp.NBLOJLDLBEB.emplace();
session.send(cmd::SetGachaDecideItemScRsp, rsp);
}
} // namespace
void registerGachaHandlers() {
net::on<proto::GetGachaInfoCsReq>(cmd::GetGachaInfoCsReq, onGetGachaInfo);
net::on<proto::DoGachaCsReq>(cmd::DoGachaCsReq, onDoGacha);
net::on<proto::GetGachaCeilingCsReq>(cmd::GetGachaCeilingCsReq, onGetGachaCeiling);
net::on<proto::ExchangeGachaCeilingCsReq>(cmd::ExchangeGachaCeilingCsReq, onExchangeGachaCeiling);
net::on<proto::SetGachaDecideItemCsReq>(cmd::SetGachaDecideItemCsReq, onSetGachaDecideItem);
}
} // namespace game
+1
View File
@@ -23,6 +23,7 @@ void registerTalkHandlers();
void registerChallengeHandlers(); void registerChallengeHandlers();
void registerTierceHandlers(); void registerTierceHandlers();
void registerPeakHandlers(); void registerPeakHandlers();
void registerGachaHandlers();
void registerModuleHandlers(); void registerModuleHandlers();
void registerMiscHandlers(); void registerMiscHandlers();
+1 -20
View File
@@ -6,7 +6,6 @@
#include "core/logger.h" #include "core/logger.h"
#include "core/util.h" #include "core/util.h"
#include "data/excel.h"
#include "game/handlers.h" #include "game/handlers.h"
#include "net/cmd_ids.h" #include "net/cmd_ids.h"
#include "net/handler.h" #include "net/handler.h"
@@ -77,24 +76,6 @@ void onSyncClientResVersion(net::Session& session, const proto::SyncClientResVer
session.send(cmd::SyncClientResVersionScRsp, rsp); session.send(cmd::SyncClientResVersionScRsp, rsp);
} }
// The warp pools. Only the standard pool is offered, and its ceiling as already
// claimed: nothing can pay a warp out yet.
void onGetGachaInfo(net::Session& session, const proto::GetGachaInfoCsReq&) {
const data::StandardGacha& standard = data::Tables::get().standardGacha();
proto::GetGachaInfoScRsp rsp;
rsp.retcode = 0;
auto& pool = rsp.gacha_info_list.emplace_back();
pool.gacha_id = standard.gachaId;
auto& ceiling = pool.gacha_ceiling.emplace();
ceiling.is_claimed = true;
for (uint32_t avatarId : standard.ceilingAvatars) {
proto::GachaCeilingAvatar avatar;
avatar.avatar_id = avatarId;
ceiling.avatar_list.push_back(avatar);
}
session.send(cmd::GetGachaInfoScRsp, rsp);
}
// Activities reported as scheduled: module id, panel and window. // Activities reported as scheduled: module id, panel and window.
struct ScheduledActivity { struct ScheduledActivity {
uint32_t activityId; uint32_t activityId;
@@ -207,7 +188,6 @@ void registerMiscHandlers() {
for (uint16_t cmdId : kEmptyReplies) net::Handlers::get().addEmpty(cmdId); for (uint16_t cmdId : kEmptyReplies) net::Handlers::get().addEmpty(cmdId);
for (const auto& [req, rsp] : kResponseAliases) net::Handlers::get().addAlias(req, rsp); for (const auto& [req, rsp] : kResponseAliases) net::Handlers::get().addAlias(req, rsp);
net::on<proto::SetClientPausedCsReq>(cmd::SetClientPausedCsReq, onSetClientPaused); net::on<proto::SetClientPausedCsReq>(cmd::SetClientPausedCsReq, onSetClientPaused);
net::on<proto::GetGachaInfoCsReq>(cmd::GetGachaInfoCsReq, onGetGachaInfo);
net::on<proto::GetActivityScheduleConfigCsReq>(cmd::GetActivityScheduleConfigCsReq, net::on<proto::GetActivityScheduleConfigCsReq>(cmd::GetActivityScheduleConfigCsReq,
onGetActivityScheduleConfig); onGetActivityScheduleConfig);
net::on<proto::SyncClientResVersionCsReq>(cmd::SyncClientResVersionCsReq, net::on<proto::SyncClientResVersionCsReq>(cmd::SyncClientResVersionCsReq,
@@ -226,6 +206,7 @@ void registerAllHandlers() {
registerChallengeHandlers(); registerChallengeHandlers();
registerTierceHandlers(); registerTierceHandlers();
registerPeakHandlers(); registerPeakHandlers();
registerGachaHandlers();
registerModuleHandlers(); registerModuleHandlers();
registerMiscHandlers(); registerMiscHandlers();
logging::info("game", "{} packet handlers registered, {} answered empty", logging::info("game", "{} packet handlers registered, {} answered empty",
+4
View File
@@ -7,6 +7,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "game/gacha.h"
#include "game/lineup.h" #include "game/lineup.h"
#include "game/roster.h" #include "game/roster.h"
#include "game/scene.h" #include "game/scene.h"
@@ -168,6 +169,8 @@ public:
const std::map<uint32_t, TierceProgress>& tierceHistory() const { return tierceHistory_; } const std::map<uint32_t, TierceProgress>& tierceHistory() const { return tierceHistory_; }
PeakProgress& peakProgress() { return peakProgress_; } PeakProgress& peakProgress() { return peakProgress_; }
const PeakProgress& peakProgress() const { return peakProgress_; } const PeakProgress& peakProgress() const { return peakProgress_; }
GachaProgress& gacha() { return gacha_; }
const GachaProgress& gacha() const { return gacha_; }
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; }
@@ -209,6 +212,7 @@ private:
PeakProgress peakProgress_; PeakProgress peakProgress_;
TierceRun tierce_; TierceRun tierce_;
std::map<uint32_t, TierceProgress> tierceHistory_; std::map<uint32_t, TierceProgress> tierceHistory_;
GachaProgress gacha_;
uint32_t mainCharacter_ = 8008; uint32_t mainCharacter_ = 8008;
uint32_t marchType_ = 1224; uint32_t marchType_ = 1224;
+25
View File
@@ -171,6 +171,20 @@ bool loadPlayerState(Player& player) {
} }
} }
} }
if (auto gacha = j.find("gacha"); gacha != j.end() && gacha->is_object()) {
auto readPity = [&gacha](const char* key, GachaPity& pity) {
auto it = gacha->find(key);
if (it == gacha->end() || !it->is_object()) return;
pity.sinceFive = u32(*it, "since_five", 0);
pity.sinceFour = u32(*it, "since_four", 0);
pity.guaranteed = boolOr(*it, "guaranteed", false);
pity.total = u32(*it, "total", 0);
};
readPity("standard", player.gacha().standard);
readPity("character", player.gacha().character);
readPity("lightcone", player.gacha().lightcone);
}
return true; return true;
} }
@@ -244,6 +258,17 @@ bool savePlayerState(const Player& player) {
} }
j["tierce"] = tierce; j["tierce"] = tierce;
auto pity = [](const GachaPity& p) {
return json{{"since_five", p.sinceFive},
{"since_four", p.sinceFour},
{"guaranteed", p.guaranteed},
{"total", p.total}};
};
const GachaProgress& gacha = player.gacha();
j["gacha"] = {{"standard", pity(gacha.standard)},
{"character", pity(gacha.character)},
{"lightcone", pity(gacha.lightcone)}};
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);
+79
View File
@@ -982,6 +982,85 @@ void runFlowTests() {
} }
} }
// Warp: results only, and every refusal still answers.
std::vector<core::WarpBanner> configuredPools = core::Config::get().gameplay.limitedWarpPools;
core::Config::get().gameplay.limitedWarpPools.clear();
client.sendEmpty(cmd::GetGachaInfoCsReq);
check(client.await(cmd::GetGachaInfoScRsp, packet), "warp pools answered");
proto::GetGachaInfoScRsp standardInfo;
check(parseBody(packet, standardInfo) && standardInfo.gacha_info_list.size() == 1 &&
standardInfo.gacha_info_list[0].gacha_id == 1001,
"only the standard pool by default");
core::Config::get().gameplay.limitedWarpPools = {{2002, 0}, {3002, 0}};
client.sendEmpty(cmd::GetGachaInfoCsReq);
check(client.await(cmd::GetGachaInfoScRsp, packet), "and again with limited pools configured");
proto::GetGachaInfoScRsp gachaInfo;
check(parseBody(packet, gachaInfo), "the pools parse");
check(gachaInfo.gacha_info_list.size() == 3, "the standard pool and the two configured ones");
check(!gachaInfo.gacha_info_list.empty() && gachaInfo.gacha_info_list[0].gacha_ceiling.has() &&
gachaInfo.gacha_info_list[0].gacha_ceiling->avatar_list.size() == 7,
"the standard pool carries its ceiling");
uint32_t limitedId = 0;
if (gachaInfo.gacha_info_list.size() > 1) {
limitedId = gachaInfo.gacha_info_list[1].gacha_id;
check(gachaInfo.gacha_info_list[1].prize_item_list.size() == 1,
"a limited pool names its featured 5*");
}
proto::DoGachaCsReq tenPull;
tenPull.gacha_id = limitedId;
tenPull.gacha_num = 10;
tenPull.gacha_random = gachaInfo.gacha_random;
client.send(cmd::DoGachaCsReq, tenPull);
check(client.await(cmd::DoGachaScRsp, packet), "a ten pull answers");
proto::DoGachaScRsp tenRsp;
check(parseBody(packet, tenRsp) && tenRsp.retcode == 0, "and succeeds");
check(tenRsp.gacha_id == limitedId && tenRsp.gacha_num == 10 &&
tenRsp.gacha_item_list.size() == 10,
"with ten results");
bool cardsWhole = !tenRsp.gacha_item_list.empty();
for (const proto::GachaItem& card : tenRsp.gacha_item_list) {
cardsWhole &= card.gacha_item.has() && card.gacha_item->item_id != 0 &&
card.transfer_item_list.has() && card.token_item.has();
}
check(cardsWhole, "every card is filled in");
proto::DoGachaCsReq oddPull = tenPull;
oddPull.gacha_num = 5;
client.send(cmd::DoGachaCsReq, oddPull);
proto::DoGachaScRsp oddRsp;
check(client.await(cmd::DoGachaScRsp, packet) && parseBody(packet, oddRsp) &&
oddRsp.retcode == static_cast<uint32_t>(proto::Retcode::RET_GACHA_NUM_INVALID) &&
oddRsp.gacha_item_list.empty(),
"a five pull is refused");
proto::DoGachaCsReq oldPool;
oldPool.gacha_id = 2138;
oldPool.gacha_num = 1;
client.send(cmd::DoGachaCsReq, oldPool);
proto::DoGachaScRsp oldRsp;
check(client.await(cmd::DoGachaScRsp, packet) && parseBody(packet, oldRsp) &&
oldRsp.retcode == static_cast<uint32_t>(proto::Retcode::RET_GACHA_ID_NOT_EXIST),
"a pool that is not offered is refused");
proto::GetGachaCeilingCsReq ceilingReq;
ceilingReq.DDMCNOJFGON = 1;
client.send(cmd::GetGachaCeilingCsReq, ceilingReq);
proto::GetGachaCeilingScRsp ceilingRsp;
check(client.await(cmd::GetGachaCeilingScRsp, packet) && parseBody(packet, ceilingRsp) &&
ceilingRsp.gacha_ceiling.has() && ceilingRsp.gacha_ceiling->is_claimed,
"the ceiling answers, claimed");
proto::SetGachaDecideItemCsReq decideReq;
decideReq.gacha_id = limitedId;
client.send(cmd::SetGachaDecideItemCsReq, decideReq);
proto::SetGachaDecideItemScRsp decideRsp;
check(client.await(cmd::SetGachaDecideItemScRsp, packet) && parseBody(packet, decideRsp) &&
decideRsp.retcode != 0 && decideRsp.NBLOJLDLBEB.has(),
"a custom 50/50 pool is refused with a body");
core::Config::get().gameplay.limitedWarpPools = configuredPools;
// An unimplemented request still has to complete, or the client hangs on it. This // An unimplemented request still has to complete, or the client hangs on it. This
// one has no handler at all, so it exercises the name-derived fallback. // one has no handler at all, so it exercises the name-derived fallback.
client.sendEmpty(cmd::GetStarFightDataCsReq); client.sendEmpty(cmd::GetStarFightDataCsReq);
+219
View File
@@ -2,6 +2,8 @@
// present; the data-driven checks are skipped (and reported) when they are not. // present; the data-driven checks are skipped (and reported) when they are not.
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <map>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -13,8 +15,10 @@
#include "data/scene_res.h" #include "data/scene_res.h"
#include "game/battle.h" #include "game/battle.h"
#include "game/challenge.h" #include "game/challenge.h"
#include "game/gacha.h"
#include "game/lineup.h" #include "game/lineup.h"
#include "game/player.h" #include "game/player.h"
#include "game/player_store.h"
#include "game/roster.h" #include "game/roster.h"
#include "game/scene.h" #include "game/scene.h"
#include "game/srtools.h" #include "game/srtools.h"
@@ -186,6 +190,220 @@ void testTables() {
check(tables.plane(10000) != nullptr, "plane 10000 is loaded"); check(tables.plane(10000) != nullptr, "plane 10000 is loaded");
} }
bool contains(const std::vector<uint32_t>& ids, uint32_t id) {
return std::find(ids.begin(), ids.end(), id) != ids.end();
}
uint32_t tokenCount(const proto::GachaItem& result, uint32_t itemId) {
uint32_t total = 0;
if (!result.token_item.has()) return 0;
for (const proto::Item& item : result.token_item->item_list) {
if (item.item_id == itemId) total += item.num;
}
return total;
}
void testGacha() {
const data::Tables& tables = data::Tables::get();
if (!tables.loaded()) {
std::printf("SKIP gacha (no data source configured)\n");
return;
}
check(tables.avatar(1001) != nullptr && tables.avatar(1001)->rarity == 4, "March is a 4*");
check(tables.avatar(1003) != nullptr && tables.avatar(1003)->rarity == 5, "Himeko is a 5*");
check(tables.lightcone(23000) != nullptr && tables.lightcone(23000)->rarity == 5,
"light cone rarity is read");
check(tables.lightcone(20000) != nullptr && tables.lightcone(20000)->rarity == 3,
"down to the 3* ones");
check(tables.battlePassReward(21028) && !tables.battlePassReward(21000),
"battle pass light cones are known");
// The beta client's own table has six pools, and it throws on any other.
check(tables.gachaPools().size() == 5, "the client's pools, less the beginner one");
std::vector<data::GachaPool> standardOnly = game::offeredGachaPools(tables, {});
check(standardOnly.size() == 1 && standardOnly[0].id == 1001,
"only the standard pool unless limited ones are configured");
const std::vector<core::WarpBanner> limited{{2002, 0}, {3002, 0}, {2002, 0},
{2138, 0}, {1001, 0}, {4001, 0}};
std::vector<data::GachaPool> offered = game::offeredGachaPools(tables, limited);
check(offered.size() == 3, "each configured pool once, if the table has it");
check(!offered.empty() && offered[0].id == 1001, "standard first");
std::optional<data::GachaPool> character = game::offeredGachaPool(tables, limited, 2002);
check(character && character->type == data::GachaType::AvatarUp &&
character->featured == 1204 && character->upChance == 50,
"2002 features 1204 at 50%");
std::optional<data::GachaPool> lightcone = game::offeredGachaPool(tables, limited, 3002);
check(lightcone && lightcone->type == data::GachaType::WeaponUp &&
lightcone->featured == 23010 && lightcone->upChance == 75,
"3002 features 23010 at 75%");
check(!game::offeredGachaPool(tables, limited, 2138), "a pool the client lacks is not offered");
check(!game::offeredGachaPool(tables, limited, 4001), "nor the beginner pool");
check(!game::offeredGachaPool(tables, {}, 2002), "nor an unconfigured one");
game::GachaItemPools items = game::gachaItemPools(tables);
check(items.complete(), "every rarity has something to draw");
check(items.fiveStarAvatars.size() == 7 && items.fiveStarLightcones.size() == 7,
"seven standard 5* of each");
check(contains(items.fourStarAvatars, 1001) && !contains(items.fourStarAvatars, 1224),
"4* characters without March's second form");
check(!contains(items.fourStarAvatars, 1003), "and without 5*s");
check(contains(items.fourStarLightcones, 21000) && !contains(items.fourStarLightcones, 21028) &&
!contains(items.fourStarLightcones, 22000),
"4* light cones without pass or event ones");
check(items.threeStarLightcones.size() == 25, "all 25 3* light cones");
if (!items.complete() || !character || !lightcone || offered.empty()) return;
auto unlucky = [] { return 0.999999; };
auto lucky = [] { return 0.0; };
game::GachaPity pity;
std::vector<game::GachaPull> pulls = game::pullGacha(offered[0], items, pity, 91, unlucky);
bool early = false;
for (size_t i = 0; i < 89; ++i) early |= pulls[i].rarity == 5;
check(!early && pulls[89].rarity == 5, "the 90th standard pull is a 5*");
check(pulls[9].rarity == 4 && pulls[19].rarity == 4 && pulls[8].rarity == 3,
"every tenth pull is a 4*");
check(pulls[90].rarity == 4, "a 5* pushes the due 4* to the next pull");
check(pity.sinceFive == 1 && pity.sinceFour == 0 && pity.total == 91, "pity is counted");
game::GachaPity lcPity;
pulls = game::pullGacha(*lightcone, items, lcPity, 80, unlucky);
early = false;
for (size_t i = 0; i < 79; ++i) early |= pulls[i].rarity == 5;
check(!early && pulls[79].rarity == 5, "the 80th light cone pull is a 5*");
game::GachaPity charPity;
pulls = game::pullGacha(*character, items, charPity, 90, unlucky);
check(pulls[89].rarity == 5 && pulls[89].avatar && pulls[89].itemId != 1204 &&
contains(items.fiveStarAvatars, pulls[89].itemId),
"losing the 50/50 gives a standard 5*");
check(charPity.guaranteed, "and guarantees the next one");
pulls = game::pullGacha(*character, items, charPity, 90, unlucky);
check(pulls[89].itemId == 1204 && !charPity.guaranteed, "which is the featured 5*");
game::GachaPity luckyPity;
pulls = game::pullGacha(*character, items, luckyPity, 10, lucky);
bool allFeatured = pulls.size() == 10;
for (const game::GachaPull& pull : pulls) allFeatured &= pull.rarity == 5 && pull.itemId == 1204;
check(allFeatured, "winning every roll gives the featured 5* every time");
pulls = game::pullGacha(offered[0], items, luckyPity, 1, lucky);
check(pulls[0].rarity == 5 && pulls[0].itemId == items.fiveStarAvatars[0], "standard draws too");
game::SrToolsData roster;
game::Avatar maxed;
maxed.avatarId = 1003;
maxed.rank = 6;
roster.avatars[1003] = maxed;
game::Avatar fresh;
fresh.avatarId = 1004;
roster.avatars[1004] = fresh;
proto::GachaItem result = game::gachaResult({20000, 3, false}, roster);
check(result.gacha_item.has() && result.gacha_item->item_id == 20000 && result.gacha_item->num == 1,
"the card names the item");
check(result.transfer_item_list.has() && result.token_item.has(), "both lists are present");
check(tokenCount(result, 251) == 20 && !result.is_new, "a 3* light cone is 20 embers");
check(tokenCount(game::gachaResult({23000, 5, false}, roster), 252) == 40, "a 5* one 40 starlight");
check(tokenCount(game::gachaResult({21000, 4, false}, roster), 252) == 8, "a 4* one 8");
result = game::gachaResult({1003, 5, true}, roster);
check(tokenCount(result, 252) == 100 && result.transfer_item_list->item_list.empty(),
"a 5* past E6 is 100 starlight");
result = game::gachaResult({1004, 5, true}, roster);
check(tokenCount(result, 252) == 40 && result.transfer_item_list->item_list.size() == 1 &&
result.transfer_item_list->item_list[0].item_id == 11004,
"below E6 it is 40 and an eidolon");
result = game::gachaResult({1101, 5, true}, roster);
check(result.is_new && tokenCount(result, 252) == 0, "a character not in the build is new");
// A configured featured 5* replaces the pool's own, if it is of the pool's kind.
std::optional<data::GachaPool> pearl = game::offeredGachaPool(tables, {{2001, 1503}}, 2001);
check(pearl && pearl->type == data::GachaType::AvatarUp && pearl->featured == 1503 &&
pearl->upChance == 50,
"2001 can feature Pearl at 50%");
std::optional<data::GachaPool> fourStar = game::offeredGachaPool(tables, {{2001, 1001}}, 2001);
check(fourStar && fourStar->featured == 1102, "a 4* cannot be featured");
std::optional<data::GachaPool> wrongKind = game::offeredGachaPool(tables, {{3001, 1503}}, 3001);
check(wrongKind && wrongKind->featured == 23001, "nor a character on a light cone pool");
if (pearl) {
game::GachaPity pearlPity;
pulls = game::pullGacha(*pearl, items, pearlPity, 10, lucky);
bool allPearl = pulls.size() == 10;
for (const game::GachaPull& pull : pulls) {
allPearl &= pull.rarity == 5 && pull.avatar && pull.itemId == 1503;
}
check(allPearl, "winning the 50/50 draws her");
pulls = game::pullGacha(*pearl, items, pearlPity, 90, unlucky);
check(pulls[89].rarity == 5 && pulls[89].avatar && pulls[89].itemId != 1503 &&
contains(items.fiveStarAvatars, pulls[89].itemId) && pearlPity.guaranteed,
"losing it gives a standard 5* character and the guarantee");
}
// Stellar Warp standing in for Pearl's banner.
data::GachaPool standardPearl = game::effectiveGachaPool(offered[0], tables, 1503);
check(standardPearl.id == 1001 && standardPearl.type == data::GachaType::AvatarUp &&
standardPearl.featured == 1503 && standardPearl.upChance == 50,
"Stellar Warp can feature Pearl at 50%");
check(game::effectiveGachaPool(offered[0], tables, 0).type == data::GachaType::Normal,
"0 keeps it the standard pool");
check(game::effectiveGachaPool(offered[0], tables, 1001).type == data::GachaType::Normal,
"a 4* cannot be featured on it");
check(game::effectiveGachaPool(*character, tables, 1503).featured == 1204,
"a limited pool is left alone");
game::GachaPity standardPearlPity;
pulls = game::pullGacha(standardPearl, items, standardPearlPity, 10, lucky);
bool allStandardPearl = pulls.size() == 10;
for (const game::GachaPull& pull : pulls) {
allStandardPearl &= pull.rarity == 5 && pull.avatar && pull.itemId == 1503;
}
check(allStandardPearl, "and it draws her on a won 50/50");
// The real dice, 200000 pulls: no two lost 50/50s in a row, and the losses spread evenly.
game::GachaPity simPity;
std::map<uint32_t, uint32_t> lostTo;
uint32_t fiveStars = 0, owed = 0, lost = 0;
bool lostTwice = false, lastLost = false;
for (int batch = 0; batch < 20000; ++batch) {
for (const game::GachaPull& pull :
game::pullGacha(standardPearl, items, simPity, 10, game::gachaRoll)) {
if (pull.rarity != 5) continue;
++fiveStars;
if (pull.featured) {
if (pull.guaranteed) ++owed;
lastLost = false;
continue;
}
++lost;
++lostTo[pull.itemId];
lostTwice |= lastLost;
lastLost = true;
}
}
check(!lostTwice, "a lost 50/50 is never followed by another");
check(owed == lost || owed + 1 == lost, "every loss is paid back by the next 5*");
double rate = static_cast<double>(fiveStars) / 200000.0;
check(rate > 0.013 && rate < 0.019, "about 1.6% of pulls are 5*");
bool even = lostTo.size() == items.fiveStarAvatars.size();
for (const auto& [id, count] : lostTo) even &= count > lost * 8 / 100 && count < lost * 20 / 100;
check(even, "a lost 50/50 lands evenly on the seven standard 5*s");
std::printf(" warp simulation: %u 5* (%.2f%%), %u lost, spread:", fiveStars, rate * 100, lost);
for (const auto& [id, count] : lostTo) std::printf(" %u=%u", id, count);
std::printf("\n");
// Pity outlives a restart.
std::string realPath = core::Config::get().paths.playerFile;
core::Config::get().paths.playerFile = "build/test-gacha-player.json";
game::Player saved(1);
saved.gacha().character = {12, 3, true, 345};
check(game::savePlayerState(saved), "pity saves");
game::Player loaded(1);
check(game::loadPlayerState(loaded), "and loads");
const game::GachaPity& back = loaded.gacha().character;
check(back.sinceFive == 12 && back.sinceFour == 3 && back.guaranteed && back.total == 345,
"unchanged");
core::Config::get().paths.playerFile = realPath;
}
void testChallengeHistory() { void testChallengeHistory() {
const data::Tables& tables = data::Tables::get(); const data::Tables& tables = data::Tables::get();
if (!tables.loaded()) return; if (!tables.loaded()) return;
@@ -816,6 +1034,7 @@ void runGameTests() {
testItemUniqueIds(); testItemUniqueIds();
testElementBuffs(); testElementBuffs();
testTables(); testTables();
testGacha();
testChallengeHistory(); testChallengeHistory();
testSceneRes(); testSceneRes();
testRosterProtos(); testRosterProtos();