init(server/game): rewrite the server in c++
This commit is contained in:
1 parent
63385b7e87
commit
04b14f5a22
273 files changed
+3450394
No files matched your search
@@ -0,0 +1,695 @@
|
||||
// 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 <algorithm>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "core/config.h"
|
||||
#include "core/util.h"
|
||||
#include "data/excel.h"
|
||||
#include "data/scene_res.h"
|
||||
#include "game/battle.h"
|
||||
#include "game/challenge.h"
|
||||
#include "game/lineup.h"
|
||||
#include "game/player.h"
|
||||
#include "game/roster.h"
|
||||
#include "game/scene.h"
|
||||
#include "game/srtools.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<uint32_t> 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<uint32_t> 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<uint32_t>& 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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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<uint32_t>& 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<proto::BattleBuff> 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};
|
||||
request.allowSrToolsOverride = false;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
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();
|
||||
testChallengeHistory();
|
||||
testSceneRes();
|
||||
testRosterProtos();
|
||||
testSceneBuild();
|
||||
testChallengeRun();
|
||||
testEveryScenePacketFits();
|
||||
testBattleBuild();
|
||||
testPropInteraction();
|
||||
testSrToolsRoundTrip();
|
||||
}
|
||||
Reference in new issue
Block a user