Files
meteorbot-old/bot/external/Botcraft/tests/botcraft_online/src/TestManager.cpp
T
2024-04-30 22:07:50 -06:00

710 lines
24 KiB
C++

#include "TestManager.hpp"
#include "MinecraftServer.hpp"
#include <catch2/catch_test_case_info.hpp>
#include <protocolCraft/Types/NBT/NBT.hpp>
#include <botcraft/Network/NetworkManager.hpp>
#include <botcraft/Game/Entities/EntityManager.hpp>
#include <botcraft/Game/Entities/LocalPlayer.hpp>
#include <botcraft/Utilities/Logger.hpp>
#include <fstream>
#include <sstream>
#include <regex>
std::string ReplaceCharacters(const std::string& in, const std::vector<std::pair<char, std::string>>& replacements = { {'"', "\\\""}, {'\n', "\\n"} });
TestManager::TestManager()
{
current_offset = {spacing_x, 2, 2 * spacing_z };
current_test_index = 0;
bot_index = 0;
}
TestManager::~TestManager()
{
}
TestManager& TestManager::GetInstance()
{
static TestManager instance;
return instance;
}
const Botcraft::Position& TestManager::GetCurrentOffset() const
{
return current_offset;
}
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
void TestManager::SetBlock(const std::string& name, const Botcraft::Position& pos, const std::map<std::string, std::string>& blockstates, const std::map<std::string, std::string>& metadata, const bool escape_metadata) const
#else
void TestManager::SetBlock(const std::string& name, const Botcraft::Position& pos, const int block_variant, const std::map<std::string, std::string>& metadata, const bool escape_metadata) const
#endif
{
MakeSureLoaded(pos);
std::stringstream command;
command
<< "setblock" << " "
<< pos.x << " "
<< pos.y << " "
<< pos.z << " "
<< ((name.size() > 10 && name.substr(0, 10) == "minecraft:") ? "" : "minecraft:") << name;
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
if (!blockstates.empty())
{
command << "[";
int index = 0;
for (const auto& [k, v] : blockstates)
{
command << k << "=";
if (v.find(' ') != std::string::npos ||
v.find(',') != std::string::npos ||
v.find(':') != std::string::npos)
{
// Make sure the whole string is between quotes and all internal quotes are escaped
command << "\"" << ReplaceCharacters(v) << "\"";
}
else
{
command << v;
}
command << (index == blockstates.size() - 1 ? "" : ",");
index += 1;
}
command << "]";
}
#else
command << " "
<< block_variant << " "
<< "replace" << " ";
#endif
if (!metadata.empty())
{
command << "{";
int index = 0;
for (const auto& [k, v] : metadata)
{
command << k << ":";
if (escape_metadata &&
(v.find(' ') != std::string::npos ||
v.find(',') != std::string::npos ||
v.find(':') != std::string::npos)
)
{
// Make sure the whole string is between quotes and all internal quotes are escaped
command << "\"" << ReplaceCharacters(v) << "\"";
}
else
{
command << v;
}
command << (index == metadata.size() - 1 ? "" : ",");
index += 1;
}
command << "}";
}
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
command << " replace";
#endif
MinecraftServer::GetInstance().SendLine(command.str());
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
MinecraftServer::GetInstance().WaitLine(
".*: Changed the block at "
+ std::to_string(pos.x) + ", "
+ std::to_string(pos.y) + ", "
+ std::to_string(pos.z) + ".*", 5000);
#else
MinecraftServer::GetInstance().WaitLine(".*: Block placed.*", 5000);
#endif
}
void TestManager::CreateBook(const Botcraft::Position& pos, const std::vector<std::string>& pages, const std::string& facing, const std::string& title, const std::string& author, const std::vector<std::string>& description)
{
int facing_id = 0;
if (facing == "south")
{
facing_id = 0;
}
else if (facing == "west")
{
facing_id = 1;
}
else if (facing == "north")
{
facing_id = 2;
}
else if (facing == "east")
{
facing_id = 3;
}
std::stringstream command;
command
#if PROTOCOL_VERSION < 477 /* < 1.14 */
<< "summon" << " "
<< "minecraft:item_frame" << " "
#else
<< "setblock" << " "
#endif
<< pos.x << " "
<< pos.y << " "
<< pos.z << " "
#if PROTOCOL_VERSION < 477 /* < 1.14 */
<< "{Facing:" << facing_id << ","
<< "Item:{"
#else
<< "minecraft:lectern"
<< "[facing=" << facing << ",has_book=true]"
<< "{"
<< "Book:{"
#endif
<< "id:\"written_book\"" << ","
<< "Count:1" << ","
<< "tag:{"
<< "pages:[";
for (size_t i = 0; i < pages.size(); ++i)
{
command
<< "\"{"
<< "\\\"text\\\"" << ":" << "\\\"" << ReplaceCharacters(pages[i], { {'\n', "\\\\n"}, {'"', "\\\\\\\""} }) << "\\\""
<< "}\"" << ((i < pages.size() - 1) ? "," : "");
}
command << "]";
if (!title.empty())
{
command << ","
<< "title" << ":" << "\"" << ReplaceCharacters(title) << "\"";
}
if (!author.empty())
{
command << ","
<< "author" << ":" << "\"" << ReplaceCharacters(author) << "\"";
}
if (!description.empty())
{
command << "," << "display" << ":"
<< "{Lore:[";
for (size_t i = 0; i < description.size(); ++i)
{
command
<< "\""
#if PROTOCOL_VERSION < 735 /* < 1.16 */
// Just a list of strings is working before 1.16(?)
<< ReplaceCharacters(description[i])
#else
// After, a JSON text element is required
<< "{" << "\\\"text\\\"" << ":" << "\\\"" << ReplaceCharacters(description[i], { { '\n', "\\\\n"}, { '"', "\\\\\\\"" } }) << "\\\"" << "}"
#endif
<< "\"" << ((i < description.size() - 1) ? "," : "");
}
command << "]}";
}
command
<< "}" // tag
<< "}" // Item
<< "}"; // Main
MinecraftServer::GetInstance().SendLine(command.str());
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */ && PROTOCOL_VERSION < 477 /* < 1.14 */
MinecraftServer::GetInstance().WaitLine(".*?: Summoned new Item Frame.*", 5000);
#elif PROTOCOL_VERSION == 340 /* 1.12.2 */
MinecraftServer::GetInstance().WaitLine(".*?: Object successfully summoned.*", 5000);
#else
MinecraftServer::GetInstance().WaitLine(
".*: Changed the block at "
+ std::to_string(pos.x) + ", "
+ std::to_string(pos.y) + ", "
+ std::to_string(pos.z) + ".*", 5000);
#endif
}
void TestManager::Teleport(const std::string& name, const Botcraft::Vector3<double>& pos, const float yaw, const float pitch) const
{
std::stringstream command;
command
<< "teleport" << " "
<< name << " "
<< pos.x << " "
<< pos.y << " "
<< pos.z << " "
<< yaw << " "
<< pitch;
MinecraftServer::GetInstance().SendLine(command.str());
MinecraftServer::GetInstance().WaitLine(".*?Teleported " + name + " to.*", 5000);
}
Botcraft::Position TestManager::GetStructureSize(const std::string& filename) const
{
std::string no_space_filename = ReplaceCharacters(filename, { {' ', "_"} });
// If there is a # in the file name, only use what's before (useful to have multiple tests sharing the same structure file)
size_t split_index = no_space_filename.find('#');
if (split_index != std::string::npos)
{
no_space_filename = no_space_filename.substr(0, split_index);
}
const std::filesystem::path filepath = MinecraftServer::GetInstance().GetStructurePath() / (no_space_filename + ".nbt");
if (!std::filesystem::exists(filepath))
{
return GetStructureSize("_default");
}
std::ifstream file(filepath.string(), std::ios::in | std::ios::binary);
file.unsetf(std::ios::skipws);
ProtocolCraft::NBT::Value nbt;
file >> nbt;
file.close();
// TODO: deal with recursive structures (structures containing structure blocks) ?
return nbt["size"].as_list_of<int>();
}
void TestManager::CreateTPSign(const Botcraft::Position& src, const Botcraft::Vector3<double>& dst, const std::vector<std::string>& texts, const std::string& facing, const TestSucess success) const
{
Botcraft::Position offset_target;
int block_rotation = 0;
if (facing == "north")
{
block_rotation = 0;
offset_target = dst + Botcraft::Position(0, 0, -1);
}
else if (facing == "south")
{
block_rotation = 8;
offset_target = dst + Botcraft::Position(0, 0, 1);
}
else if (facing == "east")
{
block_rotation = 12;
offset_target = dst + Botcraft::Position(-1, 0, 0);
}
else if (facing == "west")
{
block_rotation = 4;
offset_target = dst + Botcraft::Position(1, 0, 0);
}
std::string text_color;
switch (success)
{
case TestSucess::None:
text_color = "black";
break;
case TestSucess::Success:
text_color = "dark_green";
break;
case TestSucess::Failure:
text_color = "red";
break;
case TestSucess::ExpectedFailure:
text_color = "gold";
break;
}
#if PROTOCOL_VERSION < 763 /* < 1.20 */
std::map<std::string, std::string> lines;
#else
std::vector<std::string> lines;
lines.reserve(4);
#endif
for (size_t i = 0; i < std::min(texts.size(), static_cast<size_t>(4)); ++i)
{
std::stringstream line;
line
<< "{"
<< "\"text\":\"" << texts[i] << "\"" << ",";
if (i == 0)
{
line
<< "\"underlined\"" << ":" << "false" << ","
<< "\"color\"" << ":" << "\"" << "black" << "\"" << ","
<< "\"clickEvent\"" << ":" << "{"
<< "\"action\"" << ":" << "\"run_command\"" << ","
<< "\"value\"" << ":" << "\""
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
<< "teleport @s" << " " << offset_target.x << " " << offset_target.y << " " << offset_target.z
<< " " << "facing" << " " << dst.x << " " << dst.y << " " << dst.z
#else
<< "teleport @s" << " " << dst.x << " " << dst.y << " " << dst.z
#endif
<< "\""
<< "}";
}
else
{
line
<< "\"underlined\"" << ":" << "true" << ","
<< "\"color\"" << ":" << "\"" << text_color << "\"";
}
line << "}";
#if PROTOCOL_VERSION < 763 /* < 1.20 */
lines.insert({ "Text" + std::to_string(i + 1), line.str() });
#else
lines.push_back(line.str());
#endif
}
// There is a bug in version 1.14 (and prereleases) that requires all 4 lines
// to be specified or the server can't read the text. See: https://bugs.mojang.com/browse/MC-144316
#if PROTOCOL_VERSION > 459 /* > 1.13.2 */ && PROTOCOL_VERSION < 478 /* < 1.14.1 */
for (size_t i = texts.size(); i < 4ULL; ++i)
{
lines.insert({ "Text" + std::to_string(i + 1), "{\"text\":\"\"}" });
}
#endif
#if PROTOCOL_VERSION > 762 /* > 1.19.4 */ // In 1.20 texts are sent as a list with exactly 4 elements
std::stringstream text_content;
text_content << "{\"messages\":[";
for (size_t i = 0; i < 4ULL; ++i)
{
if (i < lines.size())
{
text_content << "\"" << ReplaceCharacters(lines[i]) << "\"";
}
else
{
text_content << "\"" << ReplaceCharacters("{\"text\":\"\"}") << "\"";
}
if (i != 3ULL)
{
text_content << ",";
}
}
text_content << "]}";
const std::string text_content_str = text_content.str();
#endif
SetBlock(
#if PROTOCOL_VERSION < 393 /* < 1.13 */
"standing_sign",
#elif PROTOCOL_VERSION < 477 /* < 1.14 */
"sign",
#else
"oak_sign",
#endif
src,
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
{
{ "rotation", std::to_string(block_rotation) }
},
#else
block_rotation,
#endif
#if PROTOCOL_VERSION < 763 /* < 1.20 */
lines
#else
{
{ "is_waxed", "true" },
{ "front_text", text_content_str },
{ "back_text", text_content_str },
},
false
#endif
);
}
void TestManager::LoadStructure(const std::string& filename, const Botcraft::Position& pos, const Botcraft::Position& load_offset) const
{
std::string no_space_filename = ReplaceCharacters(filename, { {' ', "_"} });
// If there is a # in the file name, only use what's before (useful to have multiple tests sharing the same structure file)
size_t split_index = no_space_filename.find('#');
if (split_index != std::string::npos)
{
no_space_filename = no_space_filename.substr(0, split_index);
}
const std::string& loaded = std::filesystem::exists(MinecraftServer::GetInstance().GetStructurePath() / (no_space_filename + ".nbt")) ?
no_space_filename :
"_default";
SetBlock(
"structure_block",
pos,
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
{},
#else
0,
#endif
{
{"mode", "LOAD"},
{"name", loaded},
{"posX", std::to_string(load_offset.x)},
{"posY", std::to_string(load_offset.y)},
{"posZ", std::to_string(load_offset.z)},
{"showboundingbox", "1"}
}
);
SetBlock("redstone_block", pos + Botcraft::Position(0, 1, 0));
}
void TestManager::MakeSureLoaded(const Botcraft::Position& pos) const
{
std::shared_ptr<Botcraft::World> world = chunk_loader->GetWorld();
if (chunk_loader->GetWorld()->IsLoaded(pos))
{
return;
}
Teleport(chunk_loader_name, pos);
// Wait for bot to load center and corner view_distance blocks
const int chunk_x = static_cast<int>(std::floor(pos.x / static_cast<double>(Botcraft::CHUNK_WIDTH)));
const int chunk_z = static_cast<int>(std::floor(pos.z / static_cast<double>(Botcraft::CHUNK_WIDTH)));
// -1 because sometimes corner chunks are not sent
const int view_distance = MinecraftServer::options.view_distance - 1;
std::vector<std::pair<int, int>> wait_loaded = {
{chunk_x * Botcraft::CHUNK_WIDTH, chunk_z * Botcraft::CHUNK_WIDTH},
{(chunk_x + view_distance) * Botcraft::CHUNK_WIDTH - 1, (chunk_z + view_distance) * Botcraft::CHUNK_WIDTH - 1},
{(chunk_x - view_distance) * Botcraft::CHUNK_WIDTH, (chunk_z + view_distance) * Botcraft::CHUNK_WIDTH - 1},
{(chunk_x + view_distance) * Botcraft::CHUNK_WIDTH - 1, (chunk_z - view_distance) * Botcraft::CHUNK_WIDTH},
{(chunk_x - view_distance) * Botcraft::CHUNK_WIDTH, (chunk_z - view_distance) * Botcraft::CHUNK_WIDTH}
};
if (!Botcraft::Utilities::WaitForCondition([&]()
{
for (size_t i = 0; i < wait_loaded.size(); ++i)
{
if (!world->IsLoaded(Botcraft::Position(wait_loaded[i].first, pos.y, wait_loaded[i].second)))
{
return false;
}
}
return true;
}, 15000))
{
LOG_ERROR("Timeout waiting " << chunk_loader_name << " to load surroundings from " << chunk_loader->GetLocalPlayer()->GetPosition());
for (size_t i = 0; i < wait_loaded.size(); ++i)
{
const Botcraft::Position pos(wait_loaded[i].first, pos.y, wait_loaded[i].second);
LOG_ERROR(pos << " is" << (world->IsLoaded(pos) ? "" : " not") << " loaded");
}
std::stringstream loaded;
loaded << "\n";
for (const auto& c : *world->GetChunks())
{
loaded << c.first.first << "\t" << c.first.second << ";";
}
LOG_ERROR("Loaded chunks: " << loaded.str());
throw std::runtime_error("Timeout waiting " + chunk_loader_name + " to load surroundings");
}
}
void TestManager::SetGameMode(const std::string& name, const Botcraft::GameType gamemode) const
{
std::string gamemode_string;
switch (gamemode)
{
case Botcraft::GameType::Survival:
gamemode_string = "survival";
break;
case Botcraft::GameType::Creative:
gamemode_string = "creative";
break;
case Botcraft::GameType::Adventure:
gamemode_string = "adventure";
break;
case Botcraft::GameType::Spectator:
gamemode_string = "spectator";
break;
default:
return;
}
std::stringstream command;
command
<< "gamemode" << " "
<< gamemode_string << " "
<< name;
MinecraftServer::GetInstance().SendLine(command.str());
MinecraftServer::GetInstance().WaitLine(".*? Set " + name + "'s game mode to.*", 5000);
}
void TestManager::testRunStarting(Catch::TestRunInfo const& test_run_info)
{
// Make sure the server is running and ready before the first test run
MinecraftServer::GetInstance().Initialize();
// Retrieve header size
header_size = GetStructureSize("_header_running");
chunk_loader = GetBot(chunk_loader_name, Botcraft::GameType::Spectator);
}
void TestManager::testCaseStarting(Catch::TestCaseInfo const& test_info)
{
// Retrieve test structure size
current_test_size = GetStructureSize(test_info.name);
LoadStructure("_header_running", Botcraft::Position(current_offset.x, 0, spacing_z - header_size.z));
current_offset.z = 2 * spacing_z;
}
void TestManager::testCasePartialStarting(Catch::TestCaseInfo const& test_info, uint64_t part_number)
{
// Load header
current_header_position = current_offset - Botcraft::Position(0, 2, 0);
LoadStructure("_header_running", current_header_position);
current_offset.z += header_size.z;
// Load test structure
LoadStructure(test_info.name, current_offset + Botcraft::Position(0, -1, 0), Botcraft::Position(0, 1, 0));
current_test_case_failures.clear();
section_stack = { std::filesystem::path(test_info.lineInfo.file).stem().string() };
}
void TestManager::sectionStarting(Catch::SectionInfo const& section_info)
{
section_stack.push_back(section_info.name);
}
void TestManager::assertionEnded(Catch::AssertionStats const& assertion_stats)
{
if (!assertion_stats.assertionResult.succeeded())
{
const std::filesystem::path file_path = std::filesystem::relative(assertion_stats.assertionResult.getSourceInfo().file, BASE_SOURCE_DIR);
current_test_case_failures.push_back(
ReplaceCharacters(file_path.string(), {{'\\', "/"}}) + ":" +
std::to_string(assertion_stats.assertionResult.getSourceInfo().line) + "\n\n" +
assertion_stats.assertionResult.getExpressionInMacro() + "\n\n"
"with expansion:" + "\n" +
assertion_stats.assertionResult.getExpandedExpression()
);
}
}
void TestManager::testCasePartialEnded(Catch::TestCaseStats const& test_case_stats, uint64_t part_number)
{
const bool passed = test_case_stats.totals.assertions.allPassed();
// Replace header with proper test result
LoadStructure(passed ? "_header_success" : (test_case_stats.testInfo->okToFail() ? "_header_expected_fail" : "_header_fail"), current_header_position);
// Create TP sign for the partial that just ended
CreateTPSign(
Botcraft::Position(-2 * (current_test_index + 1), 2, -2 * part_number - 5),
Botcraft::Vector3(current_offset.x, 2, current_offset.z - 1),
section_stack, "north", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure)
);
// Create back to spawn sign for the section that just ended
CreateTPSign(
Botcraft::Position(current_offset.x, 2, current_offset.z - 1),
Botcraft::Vector3(-2 * (current_test_index + 1), 2, -2 * static_cast<int>(part_number) - 5),
section_stack, "south", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure)
);
if (!passed)
{
CreateBook(
Botcraft::Position(current_offset.x + 1, 2, current_offset.z - header_size.z),
current_test_case_failures,
"north",
test_case_stats.testInfo->name + "#" + std::to_string(part_number),
"Botcraft Test Framework",
section_stack
);
}
current_offset.z += current_test_size.z + spacing_z;
// Kill all items that could exist on the floor
MinecraftServer::GetInstance().SendLine("kill @e[type=item]");
#if PROTOCOL_VERSION > 340 /* > 1.12.2 */
// In 1.12.2 server sends one line per entity killed so we can't wait without knowing
// how many there were. Just assume the command worked
MinecraftServer::GetInstance().WaitLine(".*?: (?:Killed|No entity was found).*", 5000);
#endif
}
void TestManager::testCaseEnded(Catch::TestCaseStats const& test_case_stats)
{
const bool passed = test_case_stats.totals.assertions.allPassed();
LoadStructure(passed ? "_header_success" : (test_case_stats.testInfo->okToFail() ? "_header_expected_fail" : "_header_fail"), Botcraft::Position(current_offset.x, 0, spacing_z - header_size.z));
// Create Sign to TP to current test
CreateTPSign(
Botcraft::Position(-2 * (current_test_index + 1), 2, -2),
Botcraft::Vector3(current_offset.x, 2, spacing_z - 1),
{ std::filesystem::path(test_case_stats.testInfo->lineInfo.file).stem().string(), test_case_stats.testInfo->name },
"north", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure)
);
// Create sign to TP to TP back to spawn
CreateTPSign(
Botcraft::Position(current_offset.x, 2, spacing_z - 1),
Botcraft::Vector3(-2 * (current_test_index + 1), 2, -2),
{ std::filesystem::path(test_case_stats.testInfo->lineInfo.file).stem().string(), test_case_stats.testInfo->name },
"south", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure)
);
current_test_index += 1;
current_offset.x += std::max(current_test_size.x, header_size.x) + spacing_x;
}
void TestManager::testRunEnded(Catch::TestRunStats const& test_run_info)
{
if (chunk_loader)
{
chunk_loader->Disconnect();
}
}
void TestManagerListener::testRunStarting(Catch::TestRunInfo const& test_run_info)
{
TestManager::GetInstance().testRunStarting(test_run_info);
}
void TestManagerListener::testCaseStarting(Catch::TestCaseInfo const& test_info)
{
TestManager::GetInstance().testCaseStarting(test_info);
}
void TestManagerListener::testCasePartialStarting(Catch::TestCaseInfo const& test_info, uint64_t part_number)
{
TestManager::GetInstance().testCasePartialStarting(test_info, part_number);
}
void TestManagerListener::sectionStarting(Catch::SectionInfo const& section_info)
{
TestManager::GetInstance().sectionStarting(section_info);
}
void TestManagerListener::assertionEnded(Catch::AssertionStats const& assertion_stats)
{
TestManager::GetInstance().assertionEnded(assertion_stats);
}
void TestManagerListener::testCasePartialEnded(Catch::TestCaseStats const& test_case_stats, uint64_t part_number)
{
TestManager::GetInstance().testCasePartialEnded(test_case_stats, part_number);
}
void TestManagerListener::testCaseEnded(Catch::TestCaseStats const& test_case_stats)
{
TestManager::GetInstance().testCaseEnded(test_case_stats);
}
void TestManagerListener::testRunEnded(Catch::TestRunStats const& test_run_info)
{
TestManager::GetInstance().testRunEnded(test_run_info);
}
CATCH_REGISTER_LISTENER(TestManagerListener)
std::string ReplaceCharacters(const std::string& in, const std::vector<std::pair<char, std::string>>& replacements)
{
std::string output;
output.reserve(in.size());
for (size_t i = 0; i < in.size(); ++i)
{
bool found = false;
for (size_t j = 0; j < replacements.size(); ++j)
{
if (replacements[j].first == in[i])
{
output += replacements[j].second;
found = true;
break;
}
}
if (!found)
{
output += in[i];
}
}
return output;
}