add Ep3 battle replay test

This commit is contained in:
Martin Michelsen
2025-11-05 09:02:22 -08:00
parent 8cb7d2b2fe
commit 540a41a583
10 changed files with 157 additions and 52 deletions
+1
View File
@@ -18,6 +18,7 @@ build
# Files modified by the user and/or server that don't have defaults
system/config.json
system/ep3/battle-records/*.mzr
system/ep3/battle-records/*.mzrd
system/ep3/tournament-state.json
system/licenses.nsi
+9 -6
View File
@@ -1882,13 +1882,13 @@ ChatCommandDefinition cc_ping(
co_return;
});
static string file_path_for_recording(const std::string& args, uint32_t account_id) {
static string file_path_for_recording(const std::string& args, uint32_t account_id, bool compressed) {
for (char ch : args) {
if (ch <= 0x20 || ch > 0x7E || ch == '/') {
throw runtime_error("invalid recording name");
}
}
return std::format("system/ep3/battle-records/{:010}_{}.mzrd", account_id, args);
return std::format("system/ep3/battle-records/{:010}_{}.mzr{}", account_id, args, compressed ? "" : "d");
}
ChatCommandDefinition cc_playrec(
@@ -1908,13 +1908,16 @@ ChatCommandDefinition cc_playrec(
if (!start_battle_player_immediately) {
filename = filename.substr(1);
}
string file_path = file_path_for_recording(filename, a.c->login->account->account_id);
string data;
try {
data = phosg::load_file(file_path);
data = phosg::load_file(file_path_for_recording(filename, a.c->login->account->account_id, false));
} catch (const phosg::cannot_open_file&) {
throw precondition_failed("$C4The recording does\nnot exist");
try {
data = prs_decompress(phosg::load_file(file_path_for_recording(filename, a.c->login->account->account_id, true)));
} catch (const phosg::cannot_open_file&) {
throw precondition_failed("$C4The recording does\nnot exist");
}
}
auto record = make_shared<Episode3::BattleRecord>(data);
auto battle_player = make_shared<Episode3::BattleRecordPlayer>(s->io_context, record);
@@ -2288,7 +2291,7 @@ ChatCommandDefinition cc_saverec(
if (!a.c->ep3_prev_battle_record) {
throw precondition_failed("$C4No finished\nrecording is\npresent");
}
string file_path = file_path_for_recording(a.text, a.c->login->account->account_id);
string file_path = file_path_for_recording(a.text, a.c->login->account->account_id, false);
string data = a.c->ep3_prev_battle_record->serialize();
phosg::save_file(file_path, data);
send_text_message(a.c, "$C7Recording saved");
-25
View File
@@ -174,21 +174,6 @@ string BattleRecord::serialize() const {
return std::move(w.str());
}
bool BattleRecord::writable() const {
return this->is_writable;
}
bool BattleRecord::battle_in_progress() const {
return (this->battle_start_timestamp != 0);
}
const BattleRecord::Event* BattleRecord::get_first_event() const {
if (this->events.empty()) {
return nullptr;
}
return &this->events.front();
}
void BattleRecord::add_player(
const PlayerLobbyDataDCGC& lobby_data,
const PlayerInventory& inventory,
@@ -256,16 +241,6 @@ void BattleRecord::add_random_data(const void* data, size_t size) {
this->random_stream.append(reinterpret_cast<const char*>(data), size);
}
vector<string> BattleRecord::get_all_server_data_commands() const {
vector<string> ret;
for (const auto& event : this->events) {
if (event.type == Event::Type::SERVER_DATA_COMMAND) {
ret.emplace_back(event.data);
}
}
return ret;
}
const string& BattleRecord::get_random_stream() const {
return this->random_stream;
}
+18 -4
View File
@@ -62,10 +62,25 @@ public:
explicit BattleRecord(const std::string& data);
std::string serialize() const;
bool writable() const;
bool battle_in_progress() const;
inline bool writable() const {
return this->is_writable;
}
const Event* get_first_event() const;
inline uint32_t get_behavior_flags() const {
return this->behavior_flags;
}
inline bool battle_in_progress() const {
return (this->battle_start_timestamp != 0);
}
inline const Event* get_first_event() const {
return this->events.empty() ? nullptr : &this->events.front();
}
inline std::deque<Event> get_all_events() const {
return this->events;
}
void add_player(
const PlayerLobbyDataDCGC& lobby_data,
@@ -86,7 +101,6 @@ public:
void print(FILE* stream) const;
std::vector<std::string> get_all_server_data_commands() const;
const std::string& get_random_stream() const;
private:
+6 -2
View File
@@ -231,6 +231,11 @@ int8_t Server::get_winner_team_id() const {
void Server::send(const void* data, size_t size, uint8_t command, bool enable_masking) const {
// Note: This function is (obviously) not part of the original implementation.
if (this->options.output_queue) {
this->options.output_queue->emplace_back(reinterpret_cast<const char*>(data), size);
}
if (this->has_lobby) {
auto l = this->lobby.lock();
if (!l) {
@@ -2579,8 +2584,7 @@ void Server::send_6xB6x41_to_all_clients() const {
// in the playback lobby
for (string& data : map_commands_by_language) {
if (!data.empty()) {
this->battle_record->add_command(
BattleRecord::Event::Type::BATTLE_COMMAND, std::move(data));
this->battle_record->add_command(BattleRecord::Event::Type::BATTLE_COMMAND, std::move(data));
break;
}
}
+1
View File
@@ -76,6 +76,7 @@ public:
std::shared_ptr<RandomGenerator> rand_crypt;
std::shared_ptr<const Tournament> tournament;
std::array<std::vector<uint16_t>, 5> trap_card_ids;
std::shared_ptr<std::deque<std::string>> output_queue; // For replay testing
inline bool is_nte() const {
return (this->behavior_flags & BehaviorFlag::IS_TRIAL_EDITION);
+1
View File
@@ -313,6 +313,7 @@ void Lobby::create_ep3_server() {
.rand_crypt = this->rand_crypt,
.tournament = tourn,
.trap_card_ids = s->ep3_trap_card_ids,
.output_queue = nullptr,
};
if (is_nte) {
options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION;
+111 -15
View File
@@ -3340,6 +3340,7 @@ Action a_replay_ep3_battle_commands(
.rand_crypt = make_shared<MT19937Generator>(seed),
.tournament = nullptr,
.trap_card_ids = {},
.output_queue = nullptr,
};
if (is_trial) {
options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION;
@@ -3365,36 +3366,131 @@ Action a_replay_ep3_battle_commands(
Action a_replay_ep3_battle_record(
"replay-ep3-battle-record", nullptr, +[](phosg::Arguments& args) {
auto rec = make_shared<Episode3::BattleRecord>(read_input_data(args));
auto record_data = read_input_data(args);
if (args.get<bool>("compressed")) {
record_data = prs_decompress(record_data);
}
auto rec = make_shared<Episode3::BattleRecord>(record_data);
bool use_color = isatty(fileno(stdout));
auto s = make_shared<ServerState>(get_config_filename(args));
s->load_ep3_cards();
s->load_ep3_maps();
bool is_trial = (get_cli_version(args, Version::GC_EP3) == Version::GC_EP3_NTE);
bool is_nte = rec->get_behavior_flags() & Episode3::BehaviorFlag::IS_TRIAL_EDITION;
auto output_queue = std::make_shared<std::deque<std::string>>();
Episode3::Server::Options options = {
.card_index = s->ep3_card_index,
.map_index = s->ep3_map_index,
.behavior_flags = (Episode3::BehaviorFlag::IGNORE_CARD_COUNTS |
Episode3::BehaviorFlag::ENABLE_STATUS_MESSAGES |
Episode3::BehaviorFlag::DISABLE_MASKING |
Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING),
.behavior_flags = rec->get_behavior_flags() & ~(Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING),
.opt_rand_stream = make_shared<phosg::StringReader>(rec->get_random_stream()),
.rand_crypt = make_shared<DisabledRandomGenerator>(),
.tournament = nullptr,
.trap_card_ids = {},
.output_queue = output_queue,
};
if (is_trial) {
options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION;
}
options.behavior_flags |= Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING;
auto server = make_shared<Episode3::Server>(nullptr, std::move(options));
server->init();
for (const auto& command : rec->get_all_server_data_commands()) {
phosg::log_info_f("Server data command");
phosg::print_data(stderr, command, 0, nullptr, phosg::PrintDataFlags::PRINT_ASCII | phosg::PrintDataFlags::DISABLE_COLOR | phosg::PrintDataFlags::OFFSET_16_BITS);
server->on_server_data_input(nullptr, command);
// Ignore commands generated by the server when it's constructed (these
// are not included in the battle record)
output_queue->clear();
std::array<bool, 4> players_present = {false, false, false, false};
for (const auto& ev : rec->get_all_events()) {
switch (ev.type) {
case Episode3::BattleRecord::Event::Type::SET_INITIAL_PLAYERS:
ev.print(stdout);
for (const auto& player : ev.players) {
players_present.at(player.lobby_data.client_id) = true;
phosg::fwrite_fmt(stderr, "Player {} is present\n", player.lobby_data.client_id.load());
}
break;
case Episode3::BattleRecord::Event::Type::PLAYER_JOIN:
case Episode3::BattleRecord::Event::Type::PLAYER_LEAVE:
case Episode3::BattleRecord::Event::Type::CHAT_MESSAGE:
case Episode3::BattleRecord::Event::Type::GAME_COMMAND:
case Episode3::BattleRecord::Event::Type::EP3_GAME_COMMAND:
ev.print(stdout);
break;
case Episode3::BattleRecord::Event::Type::BATTLE_COMMAND:
// Ignore the map command (this is handled separately) and 6xB4x4B
// (which is only generated when a lobby is present)
if (ev.data.empty() || (static_cast<uint8_t>(ev.data[0]) == 0xB6) || (ev.data.at(4) == 0x4B)) {
ev.print(stdout);
} else {
if (use_color) {
phosg::print_color_escape(stdout, phosg::TerminalFormat::FG_RED, phosg::TerminalFormat::BOLD, phosg::TerminalFormat::END);
}
ev.print(stdout);
if (use_color) {
phosg::print_color_escape(stdout, phosg::TerminalFormat::NORMAL, phosg::TerminalFormat::END);
fflush(stdout);
}
if (output_queue->empty()) {
phosg::fwrite_fmt(stderr, "Output queue is empty, but expected battle command:\n");
phosg::print_data(stderr, ev.data, 0, nullptr, phosg::PrintDataFlags::OFFSET_16_BITS | phosg::PrintDataFlags::PRINT_ASCII);
throw std::runtime_error("Output did not match expectations");
}
// Hack: don't check the last field in 6xB4x46 since it contains
// a timestamp on non-NTE
bool matched = false;
if ((ev.data.at(4) == 0x46) && !is_nte) {
auto received_cmd = check_size_t<G_ServerVersionStrings_Ep3_6xB4x46>(output_queue->front());
auto expected_cmd = check_size_t<G_ServerVersionStrings_Ep3_6xB4x46>(ev.data);
received_cmd.date_str2.clear(0);
expected_cmd.date_str2.clear(0);
matched = !memcmp(&received_cmd, &expected_cmd, sizeof(received_cmd));
} else {
matched = (output_queue->front() == ev.data);
}
if (!matched) {
const void* prev = (ev.data.size() == output_queue->front().size()) ? ev.data.data() : nullptr;
phosg::fwrite_fmt(stderr, "Output queue front did not match expected command; expected:\n");
phosg::print_data(stderr, ev.data, 0, nullptr, phosg::PrintDataFlags::OFFSET_16_BITS | phosg::PrintDataFlags::PRINT_ASCII);
phosg::fwrite_fmt(stderr, "Received:\n");
phosg::print_data(stderr, output_queue->front(), 0, prev, phosg::PrintDataFlags::OFFSET_16_BITS | phosg::PrintDataFlags::PRINT_ASCII);
throw std::runtime_error("Output did not match expectations");
}
output_queue->pop_front();
}
break;
case Episode3::BattleRecord::Event::Type::SERVER_DATA_COMMAND:
if (use_color) {
phosg::print_color_escape(stdout, phosg::TerminalFormat::FG_GREEN, phosg::TerminalFormat::BOLD, phosg::TerminalFormat::END);
}
ev.print(stdout);
if (use_color) {
phosg::print_color_escape(stdout, phosg::TerminalFormat::NORMAL, phosg::TerminalFormat::END);
fflush(stdout);
}
if (!output_queue->empty()) {
phosg::fwrite_fmt(stderr, "Received extra output after preceding SERVER_DATA event:\n");
phosg::print_data(stderr, output_queue->front());
throw std::runtime_error("Output did not match expectations");
}
// Hack: Set the CPU player flag if the player isn't present in the
// recording (normally this is done by checking the Lobby, but
// there's no Lobby during a replay)
if (ev.data.at(4) == 0x1B) {
string mutable_data = ev.data;
auto& cmd = check_size_t<G_SetPlayerName_Ep3_CAx1B>(mutable_data);
cmd.entry.is_cpu_player = !players_present.at(cmd.entry.client_id);
phosg::fwrite_fmt(stderr, "Overriding is_cpu_player with {}\n", cmd.entry.is_cpu_player ? "true" : "false");
server->on_server_data_input(nullptr, mutable_data);
} else {
server->on_server_data_input(nullptr, ev.data);
}
break;
default:
throw std::runtime_error("unknown event type: {}");
}
}
if (!output_queue->empty()) {
phosg::fwrite_fmt(stderr, "Received extra output after recording completed:\n");
phosg::print_data(stderr, output_queue->front());
throw std::runtime_error("Output did not match expectations");
}
});
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
EXECUTABLE="$1"
if [ -z "$EXECUTABLE" ]; then
EXECUTABLE="./newserv"
fi
$EXECUTABLE --config=tests/config.json replay-ep3-battle-record --compressed tests/replay-ep3-battle-input.mzr