feat(game): add warp pulls
This commit is contained in:
1 parent
0e2f2f71f7
commit
f27cde756c
19 files changed
+8166
-25
No files matched your search
@@ -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
|
||||
// one has no handler at all, so it exercises the name-derived fallback.
|
||||
client.sendEmpty(cmd::GetStarFightDataCsReq);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// present; the data-driven checks are skipped (and reported) when they are not.
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -13,8 +15,10 @@
|
||||
#include "data/scene_res.h"
|
||||
#include "game/battle.h"
|
||||
#include "game/challenge.h"
|
||||
#include "game/gacha.h"
|
||||
#include "game/lineup.h"
|
||||
#include "game/player.h"
|
||||
#include "game/player_store.h"
|
||||
#include "game/roster.h"
|
||||
#include "game/scene.h"
|
||||
#include "game/srtools.h"
|
||||
@@ -186,6 +190,220 @@ void testTables() {
|
||||
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() {
|
||||
const data::Tables& tables = data::Tables::get();
|
||||
if (!tables.loaded()) return;
|
||||
@@ -816,6 +1034,7 @@ void runGameTests() {
|
||||
testItemUniqueIds();
|
||||
testElementBuffs();
|
||||
testTables();
|
||||
testGacha();
|
||||
testChallengeHistory();
|
||||
testSceneRes();
|
||||
testRosterProtos();
|
||||
|
||||
Reference in new issue
Block a user