// Exercises the game layer against the real tables and scene dump when they are // present; the data-driven checks are skipped (and reported) when they are not. #include #include #include #include #include #include #include #include "core/config.h" #include "core/files.h" #include "core/util.h" #include "data/excel.h" #include "data/scene_res.h" #include "game/battle.h" #include "game/challenge.h" #include "game/gacha.h" #include "game/inventory.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" #include "game/tierce.h" #include "net/packet.h" #include "tests/harness.h" namespace { using testing::check; void testLineupPacking() { game::LineupBook book; check(book.curIndex() == 0, "lineup starts on squad 1"); check(book.maxMp() == game::kBaseMazeMp, "empty squad has the base mp cap"); check(book.join(0, 0, 1001), "join slot 0"); check(book.join(0, 1, 1002), "join slot 1"); check(book.join(0, 2, 1003), "join slot 2"); check(book.join(0, 3, 1004), "join slot 3"); check(!book.join(0, 3, 1005), "a full squad refuses a fifth member"); check(book.members(0).size() == 4, "four members"); check(book.leaderSlot(0) == 0, "the first to join leads"); // A join that names an occupied slot moves the member, never duplicates it. check(book.join(0, 0, 1004), "move 1004 to the front"); std::vector moved = book.members(0); check(moved.size() == 4, "moving keeps the squad size"); check(moved[0] == 1004 && moved[1] == 1001 && moved[2] == 1002 && moved[3] == 1003, "moving shifts the others right"); check(book.quit(0, 1002), "quit 1002"); std::vector after = book.members(0); check(after.size() == 3, "three left"); check(after[0] == 1004 && after[1] == 1001 && after[2] == 1003, "members stay packed"); check(book.swap(0, 0, 2), "swap the ends"); check(book.members(0)[0] == 1003 && book.members(0)[2] == 1004, "swap took effect"); check(!book.swap(0, 0, 3), "cannot swap with an empty slot"); // The leader follows the avatar, not the slot. book.setLeaderSlot(0, 2); check(book.squad(0).leaderAvatarId == 1004, "leader set by slot"); book.swap(0, 0, 2); check(book.leaderSlot(0) == 0, "leader follows its avatar across a swap"); book.quit(0, 1004); check(book.squad(0).leaderAvatarId != 0, "leader moves on when it leaves"); book.replace(0, {1001, 1002, 1001, 1003}, 1); check(book.members(0).size() == 3, "replace drops the duplicate"); check(book.squad(0).leaderAvatarId == 1002, "replace honours leader_slot"); // The last member cannot leave: the client refuses an empty party. book.replace(0, {8001}, 0); check(!book.quit(0, 8001), "the last member stays"); check(book.members(0).size() == 1, "squad still has its member"); } void testSquadSwitching() { game::LineupBook book; book.join(0, 0, 1001); book.join(2, 0, 1002); book.join(2, 1, 1003); book.setCurIndex(2); check(book.curIndex() == 2, "switched to squad 3"); check(book.curMembers().size() == 2, "and it is that squad's members that walk around"); // Out of range is ignored rather than wrapping; the handler answers it as an error. book.setCurIndex(game::kSquadCount); check(book.curIndex() == 2, "an out of range index changes nothing"); check(book.members(1).empty(), "squad 2 was never filled"); check(!book.squad(1).favourite, "and is not marked a favourite"); book.squad(1).favourite = true; check(book.squad(1).favourite, "the mark sticks"); } void testMazeMpCap() { game::LineupBook book; book.join(0, 0, 1001); book.setMp(99); check(book.mp() == game::kBaseMazeMp, "mp is clamped to the cap"); book.join(0, 1, 1408); // Phainon raises the cap by three check(book.maxMp() == game::kBaseMazeMp + 3, "cap raiser widens the pool"); book.setMp(8); check(book.mp() == 8, "the wider pool can be filled"); book.quit(0, 1408); check(book.maxMp() == game::kBaseMazeMp, "cap drops again"); check(book.mp() == game::kBaseMazeMp, "mp follows the cap down"); } void testItemUniqueIds() { // srtools numbers from zero and unique id 0 means "nothing", so nothing maps to 0 // and the two ranges never meet. check(game::relicUniqueId(0) == 1, "first relic is 1"); check(game::equipmentUniqueId(0) == 3001, "first lightcone is 3001"); check(game::relicUniqueId(1274) < game::equipmentUniqueId(0), "ranges do not overlap"); check(game::relicInternalUid(game::relicUniqueId(42)) == 42, "relic id round trips"); check(game::equipmentInternalUid(game::equipmentUniqueId(42)) == 42, "lightcone round trips"); check(game::equipmentInternalUid(0) == 0, "unset lightcone id stays 0"); } void testElementBuffs() { check(data::attackBuffForElement("Physical") == 1000111, "physical entry buff"); check(data::attackBuffForElement("Fire") == 1000112, "fire entry buff"); check(data::attackBuffForElement("Ice") == 1000113, "ice entry buff"); check(data::attackBuffForElement("Thunder") == 1000114, "lightning entry buff"); check(data::attackBuffForElement("Wind") == 1000115, "wind entry buff"); check(data::attackBuffForElement("Quantum") == 1000116, "quantum entry buff"); check(data::attackBuffForElement("Imaginary") == 1000117, "imaginary entry buff"); check(data::attackBuffForElement("Nonsense") == 0, "unknown element has no buff"); } void testTables() { const data::Tables& tables = data::Tables::get(); if (!tables.loaded()) { std::printf("SKIP game tables (no data source configured)\n"); return; } check(tables.avatar(1001) != nullptr, "March 7th is in the tables"); const data::AvatarInfo* march = tables.avatar(1001); check(march != nullptr && march->damageType == "Ice", "March is an ice character"); check(march != nullptr && march->attackBuffId == 1000113, "her entry buff follows her element"); check(march != nullptr && march->spNeed == 12000, "SPNeed is scaled to hundredths"); check(!march->techniqueBuffs.empty() && march->techniqueBuffs[0] == 100101, "her technique buff comes from AvatarDefaultMazeBuff"); check(tables.baseAvatarId(8008) == 8001, "a Trailblazer path folds into 8001"); check(tables.baseAvatarId(1224) == 1001, "March the Hunt folds into 1001"); check(tables.baseAvatarId(1310) == 1310, "everyone else is their own base"); check(tables.multiPathVariants(8001) != nullptr, "8001 has paths"); check(tables.multiPathVariants(1310) == nullptr, "1310 has none"); const std::vector& globals = tables.globalMazeBuffs(); check(globals.size() == 2, "two global maze buffs"); check(std::find(globals.begin(), globals.end(), 140703u) != globals.end(), "Castorice global"); check(std::find(globals.begin(), globals.end(), 150602u) != globals.end(), "Silver Wolf global"); // PlaneEvent 10301299 at world level 6 is the Express corridor fight. check(tables.stageForEvent(10301299, 6) == 103012996, "plane event resolves its stage"); check(tables.stageForEvent(10301299, 0) != 0, "an unlisted world level falls back"); check(tables.stageForEvent(999999999, 6) == 0, "an unknown event resolves to nothing"); const data::StageInfo* stage = tables.stage(103012996); check(stage != nullptr && !stage->waves.empty(), "the stage has monster waves"); // CocoonConfig is keyed by id * 100 + world level. const data::CocoonInfo* cocoon = tables.cocoon(1001, 6); check(cocoon != nullptr, "calyx 1001 exists at world level 6"); check(cocoon != nullptr && cocoon->worldLevel == 6, "and it is the world level 6 row"); check(cocoon != nullptr && !cocoon->stageIds.empty(), "with stages to draw from"); // FarmElementConfig: a shadow comes as its stage id, or as its id at a world level. check(tables.farmElementStage(1012011, 6) == 1012011, "a shadow's stage id is its own stage"); check(tables.farmElementStage(1101, 1) == 1012011, "a shadow id resolves at its world level"); check(tables.farmElementStage(999999999, 0) == 0, "an unknown shadow resolves to nothing"); const data::StandardGacha& gacha = tables.standardGacha(); check(gacha.gachaId == 1001, "the standard warp is pool 1001"); check(gacha.ceilingNum == 300 && gacha.ceilingAvatars.size() == 7, "with its seven picks at 300 pulls"); check(tables.entrance(1000001) != nullptr, "map entrance 1000001 is loaded"); check(tables.plane(10000) != nullptr, "plane 10000 is loaded"); // Main missions come from the client's own (beta) table, which lacks 2031101. const std::vector& missions = tables.mainMissions(); check(!tables.knowsMainMission(2031101) && std::find(missions.begin(), missions.end(), 2031101u) == missions.end(), "a mission the client lacks is not reported"); check(tables.knowsMainMission(1054600) && tables.knowsMainMission(1000101), "beta and production missions both are"); } bool contains(const std::vector& 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 standardOnly = game::offeredGachaPools(tables, {}); check(standardOnly.size() == 1 && standardOnly[0].id == 1001, "only the standard pool unless limited ones are configured"); const std::vector limited{{2002, 0}, {3002, 0}, {2002, 0}, {2138, 0}, {1001, 0}, {4001, 0}}; std::vector 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 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 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 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 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 fourStar = game::offeredGachaPool(tables, {{2001, 1001}}, 2001); check(fourStar && fourStar->featured == 1102, "a 4* cannot be featured"); std::optional 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 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(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 testInventory() { const data::Tables& tables = data::Tables::get(); if (!tables.loaded()) { std::printf("SKIP inventory (no data source configured)\n"); return; } namespace inventory = game::inventory; const data::StaminaRules& rules = tables.staminaRules(); check(rules.max == 300 && rules.recoverSeconds == 360 && rules.reserveMax == 2400 && rules.reserveRecoverSeconds == 1080, "stamina rules come from the tables"); check(tables.staminaPrices().size() == 8 && tables.staminaPrices()[0] == 50 && tables.staminaPerPurchase() == 60, "and the stamina prices"); game::Player player(1); game::Inventory& bag = player.inventory(); bag.stamina = 100; bag.staminaUpdatedAt = 1000; inventory::refreshStamina(player, 1000 + 360 * 5 + 10); check(bag.stamina == 105 && bag.staminaUpdatedAt == 1000 + 360 * 5, "stamina recovers a point every six minutes"); check(inventory::nextRecoverTime(player) == 1000 + 360 * 6, "and says when the next is due"); bag.stamina = 299; bag.staminaUpdatedAt = 5000; bag.reserveStamina = 0; inventory::refreshStamina(player, 5000 + 360 + 1080 * 2); check(bag.stamina == 300 && bag.reserveStamina == 2, "once full, time runs into reserve stamina"); check(inventory::nextRecoverTime(player) == 0, "and nothing more is due"); inventory::chargeStamina(player, 40, 9000); check(bag.stamina == 260 && bag.staminaUpdatedAt == 9000, "spending from full starts the clock"); uint32_t jade = bag.hcoin; uint32_t credits = bag.scoin; inventory::grant(player, {{1, 50}, {2, 1000}, {211, 3}, {22, 150}, {11, 60}}); check(bag.hcoin == jade + 50 && bag.scoin == credits + 1000, "jade and credits go to the wallet"); check(bag.items[211] == 3 && bag.stamina == 320, "materials to the bag, stamina to stamina"); check(bag.items.count(22) == 0, "and trailblaze exp nowhere"); const data::MappingInfo* calyx = inventory::dropTable(1001, 6); check(calyx != nullptr && !calyx->display.empty(), "a calyx has a drop table at world level 6"); if (calyx != nullptr) { bool credit = false; for (const data::ItemStack& drop : inventory::rollDrops(*calyx, [] { return 0.5; })) { credit |= drop.id == 2 && drop.num > 0; } check(credit, "and a run drops credits"); } const data::MappingInfo* shadow = inventory::dropTable(1101, 6); check(shadow != nullptr && shadow->worldLevel == 5, "a shadow's empty level falls back a level"); if (shadow != nullptr) { bool boss = false; for (const data::ItemStack& drop : inventory::rollDrops(*shadow, [] { return 0.5; })) { boss |= drop.id == 110406 && drop.num == 5; } check(boss, "and its boss material scales with the world level"); } check(inventory::dropTable(0, 6) == nullptr, "no table, no drops"); const data::RewardInfo* reward = tables.reward(101101); check(reward != nullptr, "RewardData loads"); if (reward != nullptr) { std::vector items = inventory::rewardItems(*reward); check(items.size() == 2 && items[0].id == 1 && items[0].num == 200 && items[1].id == 2 && items[1].num == 20000, "a reward carries its jade and items"); } std::vector merged = inventory::merged({{2, 10}, {211, 1}, {2, 5}}); check(merged.size() == 2 && merged[0].num == 15, "repeats merge"); proto::PlayerSyncScNotify sync = inventory::sync(player, {{211, 3}, {1, 50}}); check(sync.material_list.size() == 1 && sync.material_list[0].tid == 211 && sync.material_list[0].num == 3 && sync.basic_info.has(), "a sync names bag totals and carries the wallet"); // Season rewards: paid once, and the history marks them. bool unlocked = core::Config::get().gameplay.unlockAllChallenges; core::Config::get().gameplay.unlockAllChallenges = true; uint32_t before = bag.hcoin; std::vector granted; proto::TakeChallengeRewardScRsp first = game::challenge::takeRewards(player, 100, granted); check(!first.taken_reward_list.empty() && !granted.empty() && bag.hcoin > before, "a cleared season's rewards pay out"); std::vector again; proto::TakeChallengeRewardScRsp second = game::challenge::takeRewards(player, 100, again); check(second.taken_reward_list.empty() && again.empty(), "and only once"); const data::ChallengeGroupInfo* season = tables.challengeGroup(100); uint64_t line = season != nullptr ? tables.challengeRewardStars(season->rewardLineGroupId) : 0; uint64_t marked = 0; for (const proto::ChallengeGroup& group : game::challenge::history(&player).challenge_group_list) { if (group.group_id == 100) marked = group.taken_stars_count_reward; } check(marked != 0 && (marked & ~line) == 0, "the history marks what was claimed"); core::Config::get().gameplay.unlockAllChallenges = unlocked; // A cleared floor shows in its season's statistics. game::ChallengeRecord& record = player.challengeRecords()[1]; record.stars = 7; record.roundsUsed = 12; record.teams[0] = {1001, 1002}; record.teams[1] = {1003, 1004}; proto::GetChallengeGroupStatisticsScRsp stats = game::challenge::statistics(player, 100); check(stats.challenge_default.has() && stats.challenge_default->record_id == 1 && stats.challenge_default->PPBHLLOJNEK.has() && stats.challenge_default->PPBHLLOJNEK->EEJCPNAEKLJ == 3 && stats.challenge_default->PPBHLLOJNEK->round_count == 12 && stats.challenge_default->PPBHLLOJNEK->lineup_list.size() == 2, "a season's best clear shows in its statistics"); proto::GetChallengeGroupStatisticsScRsp none = game::challenge::statistics(player, 101); check(none.challenge_default.has() && !none.challenge_default->PPBHLLOJNEK.has(), "and a season with no clear shows none"); std::string realPath = core::Config::get().paths.playerFile; core::Config::get().paths.playerFile = "build/test-inventory-player.json"; check(game::savePlayerState(player), "the inventory saves"); game::Player loaded(1); check(game::loadPlayerState(loaded), "and loads"); check(loaded.inventory().items[211] == 3 && loaded.inventory().reserveStamina == bag.reserveStamina && loaded.challengeRecords().count(1) == 1 && loaded.challengeRecords().at(1).teams[1].size() == 2 && !loaded.challengeRewardsTaken().empty(), "bag, records and claimed rewards survive a restart"); core::Config::get().paths.playerFile = realPath; } void testChallengeHistory() { const data::Tables& tables = data::Tables::get(); if (!tables.loaded()) return; check(tables.challenges().size() > 700, "the three challenge modes loaded their floors"); check(tables.challengeGroups().size() > 90, "and their seasons"); // Memory of Chaos floor 1 of the first season, and the season it belongs to. const data::ChallengeInfo* floor = nullptr; for (const data::ChallengeInfo& c : tables.challenges()) { if (c.id == 1) floor = &c; } check(floor != nullptr && floor->groupId == 100, "challenge 1 is in group 100"); check(floor != nullptr && floor->targetIds.size() == 3, "a floor has three star targets"); // Reward line 1 pays out every three stars from 3 to 36. uint64_t mask = tables.challengeRewardStars(1); check((mask & (uint64_t{1} << 3)) != 0, "reward line 1 pays out at 3 stars"); check((mask & (uint64_t{1} << 36)) != 0, "and at 36"); check((mask & (uint64_t{1} << 4)) == 0, "but not at 4"); check(tables.challengeRewardStars(999999) == 0, "an unknown reward line pays nothing"); proto::GetChallengeScRsp rsp = game::challenge::history(); check(rsp.challenge_list.size() == tables.challenges().size(), "every floor is reported"); check(rsp.challenge_list[0].star == 7, "with all three of its stars"); check(rsp.max_level_list.size() == 3, "one max level per mode"); check(rsp.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage, "the challenge history fits in one kcp message"); // Anomaly Arbitration. Production rows have no arena, so the beta dump fills it in, // and places season ten, which production does not have at all. check(tables.peakGroups().size() >= 10, "every arbitration season loaded"); const data::PeakInfo* knight = tables.peak(101); check(knight != nullptr && knight->mapEntranceId == 3013501 && knight->eventId == 30501011, "a production knight gets its arena from the beta dump"); check(knight != nullptr && knight->monsters.size() == 1 && knight->monsters[0].configId == data::kPeakMarkerId, "and plants one monster over the arena's marker"); const data::PeakInfo* boss = tables.peak(1004); check(boss != nullptr && boss->boss && boss->groupId == 10, "1004 is season ten's boss"); check(boss != nullptr && boss->mapEntranceId == 3014601 && boss->mazeGroupId == 7, "on its own arena"); check(boss != nullptr && boss->hardEventId == 30510022 && boss->hardTarget == 3007, "with a hard stage and target of its own"); const data::BattleTargetInfo* turns = tables.battleTarget(3001); check(turns != nullptr && turns->param == 4 && !turns->countsDeaths, "target 3001 is four cycles or fewer"); const data::BattleTargetInfo* deaths = tables.battleTarget(3000); check(deaths != nullptr && deaths->countsDeaths, "target 3000 counts avatars lost"); } void testSceneRes() { const data::SceneRes& res = data::SceneRes::get(); if (!res.loaded()) { std::printf("SKIP scene dump (res.json not configured)\n"); return; } const data::ResFloor* floor = res.byEntry(2041101); check(floor != nullptr, "the Express parlour entry is in the dump"); if (floor == nullptr) return; check(floor->planeId == 20411, "plane id parsed out of the key"); check(floor->floorId == 20411001, "floor id parsed out of the key"); check(!floor->groups.empty(), "the floor has groups"); check(res.byFloor(20411001) != nullptr, "floor lookup goes through the entrance map"); check(res.byEntry(1) == nullptr, "an unknown entry is not invented"); // Half the entries have no teleport pad, so the entrance anchors are what put // the player somewhere sane when they arrive. const data::ResTeleport* express = res.anchor(1000002); check(express != nullptr, "the Express entrance has an anchor"); check(express == nullptr || express->pos.z != 0, "and it is a real position"); check(res.anchor(1) == nullptr, "an entry with no anchor gets none"); } // A player wired up the way a session would have it, without any networking. game::Player makeTestPlayer() { game::Player player(1337); player.setMainCharacter(8008); player.setMarchType(1224); game::LineupBook& book = player.lineups(); book.replace(0, {8001, 1001}, 0); return player; } void testRosterProtos() { if (!data::Tables::get().loaded()) return; game::Player player = makeTestPlayer(); game::Roster roster = player.roster(); check(roster.resolvePath(8001) == 8008, "8001 resolves to the chosen path"); check(roster.resolvePath(1001) == 1224, "1001 resolves to the chosen March"); check(roster.resolvePath(1310) == 1310, "everyone else resolves to themselves"); const std::vector& ids = game::Roster::baseAvatarIds(); check(!ids.empty(), "the roster has base ids"); // Derived from AvatarConfig; the 4.5 + 4.6-beta merge comes to exactly 88 base ids. check(ids.size() == 88, "the derived roster has all 88 base ids"); check(std::find(ids.begin(), ids.end(), 6022u) == ids.end(), "story-only units are excluded"); check(std::find(ids.begin(), ids.end(), 1503u) != ids.end(), "beta-only avatars are included"); check(ids.size() >= 2 && ids[0] == 8001 && ids[1] == 1001, "Trailblazer and March lead"); check(std::find(ids.begin(), ids.end(), 8008u) == ids.end(), "paths are not listed as bases"); check(std::find(ids.begin(), ids.end(), 1224u) == ids.end(), "March the Hunt is not a base"); proto::Avatar avatar = roster.toAvatar(8001); check(avatar.base_avatar_id == 8001, "avatar keeps its base id"); check(avatar.cur_multi_path_avatar_type == 8008, "and reports the active path"); check(avatar.has_taken_promotion_reward_list.size() == avatar.promotion, "promotion rewards are all claimed"); proto::Avatar other = roster.toAvatar(1310); check(other.cur_multi_path_avatar_type == 1310, "a single-path avatar reports itself"); std::vector buffs; proto::BattleAvatar battle = roster.toBattleAvatar(8001, 0, buffs); check(battle.id == 8008, "the battle avatar is the resolved path"); check(battle.index == 0, "index carried through"); check(battle.hp == 10000, "hp is a percentage in hundredths"); check(battle.sp_bar && battle.sp_bar->max_sp >= 10000, "sp is scaled to hundredths"); check(!battle.skilltree_list.empty(), "traces are filled in"); check(!buffs.empty(), "a technique buff was produced"); check(buffs[0].wave_flag == 0xFFFFFFFFu, "technique buffs apply to every wave"); check(buffs[0].owner_index == 0, "owned by the avatar that brought it"); } void testSceneBuild() { if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return; game::Player player = makeTestPlayer(); proto::SceneInfo scene; check(!game::scene::load(player, 1, 0, false, scene), "an unknown entry fails cleanly"); check(game::scene::load(player, 2041101, 0, true, scene), "the Express parlour loads"); check(scene.entry_id == 2041101, "entry id"); check(scene.plane_id == 20411, "plane id"); check(scene.floor_id == 20411001, "floor id"); check(!scene.entity_group_list.empty(), "the scene has entity groups"); check(player.location().floorId == 20411001, "committing moved the player"); // The party is group 0 and its entity ids are slot + 1. const proto::SceneEntityGroupInfo& actors = scene.entity_group_list.back(); check(actors.group_id == 0, "the party is the last group"); check(actors.entity_list.size() == 2, "both party members are in the world"); check(actors.entity_list[0].entity_id == 1, "first actor is entity 1"); check(actors.entity_list[0].actor && actors.entity_list[0].actor->base_avatar_id == 8001, "the actor carries the base id, not the path"); check(scene.leader_entity_id == 1, "leader entity id points at the leader"); // Entity id bands let a kind be told from the id alone. bool propsBanded = true; bool monstersBanded = true; size_t monsters = 0; for (const game::SceneEntity& entity : player.sceneState().entities()) { if (entity.kind == game::EntityKind::Prop && entity.entityId <= game::kPropEntityIdBase) { propsBanded = false; } if (entity.kind == game::EntityKind::Monster) { ++monsters; if (entity.entityId <= game::kMonsterEntityIdBase) monstersBanded = false; } } check(propsBanded, "props sit in their id band"); check(monstersBanded, "monsters sit in their id band"); check(player.sceneState().find(1) != nullptr, "the registry knows the actors"); proto::SceneMapInfo map = game::scene::mapInfo(player, 20411001); check(map.floor_id == 20411001, "map info is for the right floor"); check(map.chest_list.size() == 3, "the map legend has its chest counters"); proto::LineupInfo lineup = game::scene::lineupInfo(player); check(lineup.avatar_list.size() == 2, "the lineup has both members"); check(lineup.avatar_list[0].slot == 0 && lineup.avatar_list[1].slot == 1, "lineup slots are the packed indices"); check(lineup.max_mp == game::kBaseMazeMp, "lineup carries the technique cap"); (void)monsters; } // Every scene in the dump has to fit in one KCP message, or entering it silently // drops the packet and the client is left staring at a loading screen. void testChallengeRun() { if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return; const data::Tables& tables = data::Tables::get(); // Memory of Chaos floor 1: one node, in scene group 2 of map entrance 3000101. const data::ChallengeInfo* config = tables.challenge(1); check(config != nullptr, "challenge 1 is loaded"); if (config == nullptr) return; check(config->kind == data::ChallengeKind::Memory, "and it is a Memory of Chaos floor"); check(config->mapEntranceId == 3000101, "with its own map entrance"); check(config->roundLimit == 20, "and a cycle limit"); check(config->mazeBuffId != 0, "and a maze buff"); check(config->stages[0].mazeGroupId == 2, "its first node is scene group 2"); check(config->stages[0].monsters.size() == 1, "holding one planted monster"); // Pure Fiction and Apocalyptic Shadow both fight two nodes. const data::ChallengeInfo* fiction = tables.challenge(20011); check(fiction != nullptr && fiction->kind == data::ChallengeKind::Story, "20011 is Pure Fiction"); check(fiction != nullptr && fiction->stageNum == 2, "and has two nodes"); check(fiction != nullptr && !fiction->battleTargetIds.empty(), "with the score targets from its extra table"); const data::ChallengeInfo* shadow = tables.challenge(30011); check(shadow != nullptr && shadow->kind == data::ChallengeKind::Boss, "30011 is Apocalyptic Shadow"); check(shadow != nullptr && shadow->mapEntranceId2 != shadow->mapEntranceId, "whose second node has its own entrance"); game::Player player = makeTestPlayer(); game::ChallengeRun& run = player.challenge(); run.active = true; run.challengeId = config->id; run.stage = 1; run.roundsLeft = config->roundLimit; run.party[0] = {8001, 1001}; run.party[1] = {1002, 1003}; proto::SceneInfo scene; check(game::challenge::enterArena(player, scene), "the arena loads"); check(player.location().entryId == config->mapEntranceId, "and the player is standing in it"); // Only the node's own monster is there, and it is the one the maze config names -- // not the placeholder the scene dump carries. size_t monsters = 0; size_t npcs = 0; for (const proto::SceneEntityGroupInfo& group : scene.entity_group_list) { for (const proto::SceneEntityInfo& entity : group.entity_list) { if (entity.npc_monster) { ++monsters; check(entity.npc_monster->monster_id == config->stages[0].monsters[0].npcMonsterId, "the planted monster replaces the dump's placeholder"); check(entity.group_id == config->stages[0].mazeGroupId, "and stands in the node's own group"); } if (entity.npc) ++npcs; } } check(monsters == 1, "exactly one monster is in the arena"); check(npcs == 0, "and nobody is standing around"); // A challenge event is its own stage id -- PlaneEvent maps it to itself, so the // entity can carry it directly and skip the world-level lookup entirely. uint32_t eventId = config->stages[0].monsters[0].eventId; check(tables.stageForEvent(eventId, player.worldLevel()) == eventId, "a challenge event maps to itself"); const game::SceneEntity* planted = nullptr; for (const game::SceneEntity& entity : player.sceneState().entities()) { if (entity.kind == game::EntityKind::Monster) planted = &entity; } check(planted != nullptr && planted->stageId == eventId, "so the entity carries the stage id"); check(tables.stage(eventId) != nullptr, "and that stage is in the tables"); // Stars: cycles left and nobody dead, both met on a fresh run. check(game::challenge::stars(player) == 7, "an untouched run has all three stars"); run.roundsLeft = 1; run.deadAvatars = 2; check(game::challenge::stars(player) == 0, "a slow run with deaths has none"); // Score targets are Pure Fiction's, so a Memory floor ignores them entirely. run.challengeId = fiction->id; run.roundsLeft = 0; run.score[0] = 30000; run.score[1] = 30000; check(game::challenge::stars(player) == 7, "60000 points clears all three score targets"); run.score[1] = 0; check(game::challenge::stars(player) == 0, "30000 clears none of them"); } void testEveryScenePacketFits() { if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return; game::Player player = makeTestPlayer(); size_t worst = 0; uint32_t worstEntry = 0; size_t failures = 0; size_t built = 0; for (const data::ResFloor& floor : data::SceneRes::get().floors()) { proto::SceneInfo scene; if (!game::scene::load(player, floor.entryId, 0, false, scene)) continue; ++built; proto::EnterSceneByServerScNotify notify; notify.scene = std::move(scene); notify.lineup = game::scene::lineupInfo(player); size_t size = notify.serialize().size() + net::kPacketOverhead; if (size > worst) { worst = size; worstEntry = floor.entryId; } if (size > net::kMaxKcpMessage) ++failures; } check(built > 100, "the dump produced scenes to measure"); check(failures == 0, "every scene fits in one kcp message"); if (failures != 0) { std::printf(" largest scene is entry %u at %zu bytes (limit %zu)\n", worstEntry, worst, net::kMaxKcpMessage); } // The bag is the other packet that grows with the player's data. game::Roster roster = player.roster(); proto::GetBagScRsp bag; for (const game::Lightcone& lightcone : roster.data().lightcones) { bag.equipment_list.push_back(roster.toEquipment(lightcone)); } for (const game::Relic& relic : roster.data().relics) { bag.relic_list.push_back(roster.toRelic(relic)); } check(bag.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage, "the bag fits in one kcp message"); } void testBattleBuild() { if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return; game::Player player = makeTestPlayer(); proto::SceneInfo scene; if (!game::scene::load(player, 1000002, 0, true, scene)) { std::printf("SKIP battle build (entry 1000002 missing from the dump)\n"); return; } // Find a monster the dump placed in this scene and swing at it. uint32_t monsterEntity = 0; for (const game::SceneEntity& entity : player.sceneState().entities()) { if (entity.kind == game::EntityKind::Monster && entity.eventId != 0) { monsterEntity = entity.entityId; break; } } check(monsterEntity != 0, "the scene has a monster to fight"); if (monsterEntity == 0) return; game::BattleRequest request; request.casterEntityId = 1; request.skillIndex = 0; request.monsterEntityIds = {monsterEntity}; proto::SceneBattleInfo info = game::battle::create(player, request); check(info.battle_id != 0, "the fight got an id"); check(info.stage_id != 0, "the monster's event resolved to a stage"); check(info.battle_avatar_list.size() == 2, "the whole party is in the fight"); check(!info.monster_wave_list.empty(), "the fight has monsters"); check(info.monster_wave_list[0].battle_wave_id == 1, "waves are numbered from one"); check(info.world_level == player.worldLevel(), "world level carried through"); check(player.battle().active, "the fight is recorded on the player"); check(player.battle().battleId == info.battle_id, "and by its id"); // A basic attack gives the leader the element entry buff at SkillIndex 1. bool entryBuff = false; for (const proto::BattleBuff& buff : info.buff_list) { if (buff.id < 1000111 || buff.id > 1000117) continue; entryBuff = true; check(buff.owner_index == 0, "the entry buff belongs to whoever swung"); auto it = buff.dynamic_values.find("SkillIndex"); check(it != buff.dynamic_values.end() && it->second == 1.0f, "a basic attack is SkillIndex 1"); } check(entryBuff, "the swing produced an element entry buff"); bool ambush = false; for (const proto::BattleBuff& buff : info.buff_list) { if (buff.id == 1000102) ambush = true; } check(!ambush, "no ambush buff when the player struck first"); // Being attacked adds the ambush buff, and only to the first wave. request.attackedByEntityId = monsterEntity; request.skillIndex = 1; proto::SceneBattleInfo ambushed = game::battle::create(player, request); const proto::BattleBuff* ambushBuff = nullptr; for (const proto::BattleBuff& buff : ambushed.buff_list) { if (buff.id == 1000102) ambushBuff = &buff; } check(ambushBuff != nullptr, "an ambush adds its buff"); check(ambushBuff == nullptr || ambushBuff->wave_flag == 1, "ambush only hits wave one"); check(ambushed.battle_id != info.battle_id, "each fight gets a fresh id"); for (const proto::BattleBuff& buff : ambushed.buff_list) { if (buff.id < 1000111 || buff.id > 1000117) continue; auto it = buff.dynamic_values.find("SkillIndex"); check(it != buff.dynamic_values.end() && it->second == 2.0f, "a technique is SkillIndex 2"); } // Global maze buffs ride along unless they are switched off. bool castorice = false; for (const proto::BattleBuff& buff : ambushed.buff_list) { if (buff.id == 140703) castorice = true; } check(castorice, "the global maze buffs are added"); player.setGlobalBuffs(false); proto::SceneBattleInfo plain = game::battle::create(player, request); bool stillThere = false; for (const proto::BattleBuff& buff : plain.buff_list) { if (buff.id == 140703) stillThere = true; } check(!stillThere, "and can be switched off"); } // The third node 4.6 bolts onto a floor: its own arena and monster, everything else // borrowed from the floor it extends. void testTierceRun() { if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return; const data::Tables& tables = data::Tables::get(); const data::ChallengeTierceInfo* config = tables.challengeTierce(5213); check(config != nullptr, "the tierce table is loaded"); if (config == nullptr) return; check(config->preChallengeId == 5212, "5213 extends Memory of Chaos floor 5212"); check(config->roundLimit == 45, "with a cycle pool for the whole floor"); check(config->mazeGroupId == 11, "its node is scene group 11"); check(config->monsters.size() == 1, "holding one planted monster"); check(config->targetIds.size() == 3, "and three star targets"); check(tables.challengeTierceFor(5212) == config, "the floor finds it the other way too"); const data::ChallengeInfo* floor = tables.challenge(config->preChallengeId); check(floor != nullptr, "the floor it extends is loaded"); if (floor == nullptr) return; game::Player player = makeTestPlayer(); std::vector stages(3); for (uint32_t stage = 0; stage < 3; ++stage) { proto::AvatarIdentifier one; one.id = 8001 + stage; stages[stage].lineup.push_back(one); stages[stage].buff_id = 1000 + stage; } proto::SceneInfo scene; uint32_t retcode = game::tierce::start(player, 5213, false, 0, stages, scene); check(retcode == 0, "the floor starts"); if (retcode != 0) return; check(player.tierce().active, "the run is recorded on the player"); check(player.tierce().stage == 0, "on its first node"); check(player.tierce().roundsLeft == 45, "with the whole cycle pool"); check(player.location().entryId == floor->mapEntranceId, "and the first node is the floor's own arena"); // The third node is the one the tierce row plants, not one of the floor's two. player.tierce().stage = 2; game::ChallengeArena storage; const game::ChallengeArena* arena = game::tierce::arena(player, storage); check(arena != nullptr && arena->mazeGroupId == config->mazeGroupId, "the last node fights in the tierce's own group"); check(arena != nullptr && arena->monsters == &config->monsters, "with the tierce's own monster"); player.tierce().stage = 0; // What the node brings to a fight comes from both rows. game::BattleRequest request; game::tierce::prepareBattle(player, request); check(request.party.size() == 1, "the node fights with its own team"); check(request.mazeBuffId == floor->mazeBuffId, "under the floor's maze buff"); check(request.stageBuffId == 1000, "and the buff picked for this node"); check(request.roundsLimit == 45, "with the cycles left in the pool"); // Starting again keeps the spot the first attempt left from. game::SceneLocation origin = player.tierce().origin; proto::SceneInfo again; check(game::tierce::start(player, 5213, false, 0, stages, again) == 0, "a retry starts"); check(player.tierce().origin.entryId == origin.entryId, "and goes back to the same place"); // Every floor with a third node shows up in the history, under the tierce's id. proto::GetChallengeTierceDataScRsp history = game::tierce::history(player); bool found = false; for (const proto::ChallengeTierceData& entry : history.challenge_info_list) { if (entry.challenge_id != 5213) continue; found = true; check(entry.stage_info_list.size() == 3, "with a slot for each of its three nodes"); } check(found, "the floor is in the history"); } // "auto" hands a calyx over to the srtools build and leaves every other fight on the // stage the client named. See gameplay.battle_source. void testBattleSourceSplit() { if (!data::Tables::get().loaded()) return; if (core::Config::get().gameplay.battleSource != "auto") return; game::Player player = makeTestPlayer(); game::Roster roster = player.roster(); const game::BattleConfig& build = roster.data().battle; if (build.waves.empty() || build.stageId == 0) { std::printf("SKIP battle source split (the build on disk names no fight)\n"); return; } const data::CocoonInfo* cocoon = data::Tables::get().cocoon(1001, player.worldLevel()); if (cocoon == nullptr || cocoon->stageIds.empty()) { std::printf("SKIP battle source split (calyx 1001 is not in the tables)\n"); return; } uint32_t calyxStage = cocoon->stageIds.front(); check(calyxStage != build.stageId, "the calyx and the build name different stages"); game::BattleRequest calyx; calyx.cocoonId = 1001; calyx.wave = 1; calyx.stageIds.push_back(calyxStage); calyx.allowSrToolsOverride = true; proto::SceneBattleInfo run = game::battle::create(player, calyx); check(run.stage_id == build.stageId, "a calyx fights the srtools build"); game::BattleRequest shadow; shadow.stageIds.push_back(calyxStage); proto::SceneBattleInfo other = game::battle::create(player, shadow); check(other.stage_id == calyxStage, "every other fight keeps its own stage"); } // Writing an equip change back must not drop the parts of the build we do not // model -- the loadout presets are the user's, not ours. void testSrToolsRoundTrip() { const std::string original = core::Config::get().paths.srtoolsFile; bool ok = false; std::string source = util::readFile(original, &ok); if (!ok) { std::printf("SKIP srtools round trip (no build on disk)\n"); return; } const std::string scratch = "build/test-srtools.json"; check(util::writeFile(scratch, source), "copied the build to a scratch file"); core::Config::get().paths.srtoolsFile = scratch; check(game::SrTools::instance().loadFromDisk(), "scratch build loads"); uint32_t movedItemId = 0; game::SrTools::instance().mutate([&](game::SrToolsData& data) { if (!data.lightcones.empty()) { data.lightcones.front().equipAvatar = 1310; movedItemId = data.lightcones.front().itemId; } }); // The write is queued; wait for it before reading. files::flush(); std::string written = util::readFile(scratch, &ok); check(ok, "the build was written back"); if (ok) { nlohmann::json before = nlohmann::json::parse(source); nlohmann::json after = nlohmann::json::parse(written); for (const auto& entry : before.items()) { if (entry.key() == "avatars" || entry.key() == "lightcones" || entry.key() == "relics") { continue; } check(after.contains(entry.key()), "unmodelled fields survive a write"); if (after.contains(entry.key())) { check(after[entry.key()] == entry.value(), "and survive unchanged"); } } check(after["lightcones"].size() == before["lightcones"].size(), "no lightcones lost"); check(after["relics"].size() == before["relics"].size(), "no relics lost"); check(after["avatars"].size() == before["avatars"].size(), "no avatars lost"); check(after["lightcones"][0]["equip_avatar"] == 1310, "the equip change was written"); check(after["lightcones"][0]["item_id"] == movedItemId, "on the right lightcone"); } // A reload of the written file has to produce the same thing we just wrote. check(game::SrTools::instance().loadFromDisk(), "the written build reloads"); auto reloaded = game::SrTools::instance().data(); check(!reloaded->lightcones.empty() && reloaded->lightcones.front().equipAvatar == 1310, "and the change survived the round trip"); check(!reloaded->avatars.empty(), "avatars survived the round trip"); if (auto march = reloaded->avatars.find(1001); march != reloaded->avatars.end()) { check(!march->second.skills.empty(), "traces survived the round trip"); check(!march->second.skillsByAnchor.empty(), "trace anchors survived too"); } core::Config::get().paths.srtoolsFile = original; game::SrTools::instance().loadFromDisk(); } // Launching build\bin\capysr.exe directly used to start a server with no config, no // tables and no scenes, and every symptom looked like a broken server. void testRootDirLookup() { check(!util::executableDir().empty(), "the executable knows where it lives"); std::string root = util::findRootDir("config/config.json"); check(!root.empty(), "the repo root is found from the test binary"); check(util::fileExists(root + "/config/config.json"), "and it really holds the config"); check(util::findRootDir("no/such/marker.json").empty(), "a bogus marker finds nothing"); // The working directory wins, so a deliberate cd still decides. check(util::findRootDir("config/config.json") == root, "lookup is stable"); } void testPropInteraction() { if (!data::Tables::get().loaded()) return; game::Player player = makeTestPlayer(); game::SceneEntity prop; prop.entityId = 1001; prop.kind = game::EntityKind::Prop; prop.state = 12; // ChestClosed player.sceneState().add(prop); game::SceneEntity* stored = player.sceneState().find(1001u); check(stored != nullptr, "the prop is in the registry"); // InteractConfig 1010 opens a prop that is Closed; a chest is not, so it stays. const data::InteractInfo* open = data::Tables::get().interact(1010); check(open != nullptr, "InteractConfig 1010 is loaded"); check(open == nullptr || open->targetState == 1, "and it opens things"); check(data::propStateFromName("ChestUsed") == 13, "prop state names map to numbers"); check(data::propStateFromName("Closed") == 0, "Closed is zero"); check(data::propStateFromName("Nonsense") == 0, "an unknown state reads as Closed"); } } // namespace void runGameTests() { // The tests read the same config the server does, so they exercise the real paths. // Find the root the same way main() does, so the suite runs from anywhere. testRootDirLookup(); std::string root = util::findRootDir("config/config.json"); if (!root.empty()) util::setCurrentDir(root); core::Config::get().load("config/config.json"); // Anything that saves goes to a scratch file: a test must never write the save a // real client is using. core::Config::get().paths.playerFile = "build/test-player.json"; data::Tables::get().load(core::Config::get().paths.dataSources); data::SceneRes::get().load(core::Config::get().paths.sceneRes); data::SceneRes::get().loadAnchors(core::Config::get().paths.anchors); game::SrTools::instance().loadFromDisk(); testLineupPacking(); testSquadSwitching(); testMazeMpCap(); testItemUniqueIds(); testElementBuffs(); testTables(); testGacha(); testInventory(); testChallengeHistory(); testSceneRes(); testRosterProtos(); testSceneBuild(); testChallengeRun(); testTierceRun(); testEveryScenePacketFits(); testBattleBuild(); testBattleSourceSplit(); testPropInteraction(); testSrToolsRoundTrip(); }