From 9a1ba569829bb4a2fd6d6498606ed8628a93e74c Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Tue, 6 Dec 2022 00:16:13 -0800 Subject: [PATCH] implement episode 3 tournaments --- CMakeLists.txt | 1 + README.md | 22 +- src/Client.cc | 9 + src/Client.hh | 4 + src/CommandFormats.hh | 70 +++--- src/Episode3/DataIndex.cc | 46 +++- src/Episode3/DataIndex.hh | 19 ++ src/Episode3/PlayerState.cc | 4 +- src/Episode3/Server.cc | 85 +++++-- src/Episode3/Server.hh | 8 +- src/Episode3/Tournament.cc | 449 ++++++++++++++++++++++++++++++++++++ src/Episode3/Tournament.hh | 164 +++++++++++++ src/Lobby.hh | 1 + src/Menu.hh | 2 + src/ReceiveCommands.cc | 422 ++++++++++++++++++++++++--------- src/SendCommands.cc | 370 +++++++++++++++++++++++++++-- src/SendCommands.hh | 28 +++ src/Server.cc | 20 +- src/ServerShell.cc | 139 +++++++++++ src/ServerState.cc | 8 +- src/ServerState.hh | 6 +- src/StaticGameData.cc | 19 ++ src/StaticGameData.hh | 2 + system/ep3/com-decks.json | 14 ++ 24 files changed, 1721 insertions(+), 191 deletions(-) create mode 100644 src/Episode3/Tournament.cc create mode 100644 src/Episode3/Tournament.hh create mode 100644 system/ep3/com-decks.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 3fce0be1..2030c5d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,7 @@ add_executable(newserv src/Episode3/PlayerStateSubordinates.cc src/Episode3/RulerServer.cc src/Episode3/Server.cc + src/Episode3/Tournament.cc src/FileContentsCache.cc src/FunctionCompiler.cc src/GSLArchive.cc diff --git a/README.md b/README.md index 585fbc44..be821880 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,31 @@ newserv supports several versions of PSO. Specifically: *Notes:* 1. *DC support has only been tested with the US versions of PSO DC. Other versions probably don't work, but will be easy to add. Please submit a GitHub issue if you have a non-US DC version, and can provide a log from a connection attempt.* 2. *This version only supports the modem adapter, which Dolphin does not currently emulate, so it's difficult to test.* -3. *Episode 3 players can download quests, join lobbies, create and join games, trade cards, and participate in card auctions. CARD battles are implemented but are not well-tested. Spectator teams are partially implemented, but are entirely untested. Tournaments are not implemented.* +3. *See the following section about Episode 3 functionality.* 4. *newserv's implementation of PSOX is based on disassembly of the client executable; it has never been tested with a real client and most likely doesn't work.* 5. *Some basic features are not implemented in Blue Burst games, so the games are not very playable. A lot of work has to be done to get BB games to a playable state.* 6. *Support for PSO Dreamcast Trial Edition is very incomplete and probably never will be complete. This is really just exploring a curiosity that sheds some light on early network engineering done by Sega, not an actual attempt at supporting this version of the game.* +### Episode 3 + +The following Episode 3 features are well-tested and work normally: +* Downloading quests. +* Creating and joining games. +* Trading cards. +* Participating in card auctions. (The auction contents must be configured in config.json.) + +The following Episode 3 features are implemented, but only partially tested: +* CARD battles. If you find a feature or card ability that doesn't work, please make a GitHub issue and describe the situation (including the attacking card(s), defending card(s), and ability that didn't work). +* Spectator teams are partially implemented, but are entirely untested. +* Battle replays sometimes cause the client to crash during the replay. Using the $playrec command is therefore not recommended. +* Tournaments. + +Tournaments work differently than they did on Sega's servers. Tournaments can be created with the `create-tournament` shell command, which enables players to register for them. (Use `help` to see all the arguments - there are many!) The `start-tournament` shell command starts the tournament, but this doesn't schedule any matches. Instead, players who are scheduled for a match can all stand at a battle table in a CARD lobby, and the tournament match will start automatically. (This also means that, for example, not all matches in round 1 must be complete before round 2 can begin - only the matches preceding each individual match must be complete for that match to be playable.) + +Because newserv gives all players 1000000 meseta, there is no reward for winning a tournament. This may change in the future. + +COM decks for tournaments are defined in system/ep3/com-decks.json. The default decks in that file come from logs from Sega's servers, so the file doesn't include every COM deck Sega ever made - the rest are probably lost to time. + ## Usage Currently newserv should build on macOS and Ubuntu. It will likely work on other Linux flavors too. It should work on Windows as well, but I haven't tested it recently - the build process could be very manual. Cygwin is likely the easiest Windows environment in which to build newserv. diff --git a/src/Client.cc b/src/Client.cc index fec43abb..cd13988b 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -78,6 +78,15 @@ Client::Client( } } +Client::~Client() { + if (!this->disconnect_hooks.empty()) { + this->log.warning("Disconnect hooks pending at client destruction time:"); + for (const auto& it : this->disconnect_hooks) { + this->log.warning(" %s", it.first.c_str()); + } + } +} + void Client::set_license(shared_ptr l) { this->license = l; this->game_data.guild_card_number = this->license->serial_number; diff --git a/src/Client.hh b/src/Client.hh index ca3d890c..97919fb4 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -15,6 +15,7 @@ #include "PSOProtocol.hh" #include "Text.hh" #include "Episode3/BattleRecord.hh" +#include "Episode3/Tournament.hh" @@ -96,6 +97,7 @@ struct Client { bool should_send_to_proxy_server; uint32_t proxy_destination_address; uint16_t proxy_destination_port; + std::unordered_map> disconnect_hooks; // Patch server std::vector patch_file_checksum_requests; @@ -113,6 +115,7 @@ struct Client { std::unique_ptr save_game_data_event; int16_t card_battle_table_number; uint8_t card_battle_table_seat_number; + std::weak_ptr ep3_tournament_team; // Miscellaneous (used by chat commands) uint32_t next_exp_value; // next EXP value to give @@ -137,6 +140,7 @@ struct Client { std::shared_ptr loading_dol_file; Client(struct bufferevent* bev, GameVersion version, ServerBehavior server_behavior); + ~Client(); inline GameVersion version() const { return this->channel.version; diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index 9a4bc80c..0d8cad6d 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -2318,8 +2318,8 @@ struct S_TournamentList_GC_Ep3_E0 { struct Entry { le_uint32_t menu_id = 0; le_uint32_t item_id = 0; - uint8_t unknown_a1; - uint8_t locked; // If nonzero, the lock icon appears in the menu + uint8_t unknown_a1 = 0; + uint8_t locked = 0; // If nonzero, the lock icon appears in the menu // Values for the state field: // 00 = Preparing // 01 = 1st Round @@ -2336,8 +2336,8 @@ struct S_TournamentList_GC_Ep3_E0 { // appear that are obviously not intended to appear in the tournament list, // like "View the board" and "Board: Write". (In fact, some of the strings // listed above may be unintended for this menu as well.) - uint8_t state; - uint8_t unknown_a2; + uint8_t state = 0; + uint8_t unknown_a2 = 0; le_uint32_t start_time = 0; // In seconds since Unix epoch ptext name; le_uint16_t num_teams = 0; @@ -2349,13 +2349,13 @@ struct S_TournamentList_GC_Ep3_E0 { // E0 (C->S): Request team and key config (BB) -// E1 (S->C): Battle information (Episode 3) +// E1 (S->C): Game information (Episode 3) -struct S_Unknown_GC_Ep3_E1 { - /* 0004 */ parray game_name; +struct S_GameInformation_GC_Ep3_E1 { + /* 0004 */ ptext game_name; struct Entry { - ptext name; - ptext description; + ptext name; // From disp.name + ptext description; // Usually something like "FOmarl CLv30 J" } __packed__; /* 0024 */ parray entries; /* 00E4 */ parray unknown_a3; @@ -2384,24 +2384,24 @@ struct S_Unknown_GC_Ep3_E1 { // command. struct S_TournamentEntryList_GC_Ep3_E2 { - le_uint16_t unknown_a1 = 0; - le_uint16_t unknown_a2 = 0; + le_uint16_t players_per_team = 0; + le_uint16_t unused = 0; struct Entry { le_uint32_t menu_id = 0; le_uint32_t item_id = 0; - uint8_t unknown_a1; + uint8_t unknown_a1 = 0; // If locked is nonzero, a lock icon appears next to this team and the // player is prompted for a password if they select this team. - uint8_t locked; + uint8_t locked = 0; // State values: // 00 = empty (team_name is ignored; entry is selectable) // 01 = present, joinable (team_name renders in white) // 02 = present, finalized (team_name renders in yellow) // If state is any other value, the entry renders as if its state were 02, // but cannot be selected at all (the menu cursor simply skips over it). - uint8_t state; - uint8_t unknown_a2; - ptext team_name; + uint8_t state = 0; + uint8_t unknown_a2 = 0; + ptext name; } __packed__; parray entries; } __packed__; @@ -5214,12 +5214,12 @@ struct G_Unknown_GC_Ep3_6xB5x3C { parray unused; } __packed__; -// 6xB4x3D: Unknown -// TODO: Document this from Episode 3 client/server disassembly -// This may be tournament metadata. +// 6xB4x3D: Set tournament player decks +// This is sent before the counter sequence in a tournament game, to reserve the +// player and COM slots and set the map number. -struct G_Unknown_GC_Ep3_6xB4x3D { - G_CardBattleCommandHeader header = {0xB4, sizeof(G_Unknown_GC_Ep3_6xB4x3D) / 4, 0, 0x3D, 0, 0, 0}; +struct G_SetTournamentPlayerDecks_GC_Ep3_6xB4x3D { + G_CardBattleCommandHeader header = {0xB4, sizeof(G_SetTournamentPlayerDecks_GC_Ep3_6xB4x3D) / 4, 0, 0x3D, 0, 0, 0}; Episode3::Rules rules; parray unknown_a1; struct Entry { @@ -5228,13 +5228,14 @@ struct G_Unknown_GC_Ep3_6xB4x3D { ptext deck_name; // Seems to only be used for COM players parray unknown_a1; parray card_ids; - parray unused; + uint8_t client_id = 0; // Unused for COMs + uint8_t unknown_a4 = 0; le_uint16_t unknown_a2 = 0; le_uint16_t unknown_a3 = 0; } __packed__; parray entries; le_uint32_t map_number = 0; - uint8_t unknown_a2 = 0; + uint8_t player_slot = 0; // Which slot is editable by the client uint8_t unknown_a3 = 0; uint8_t unknown_a4 = 0; uint8_t unknown_a5 = 0; @@ -5481,20 +5482,25 @@ struct G_SetTrapTileLocations_GC_Ep3_6xB4x50 { parray unused; } __packed__; -// 6xB4x51: Tournament match info +// 6xB4x51: Tournament match result +// This is sent as soon as the battle result is determined (before the battle +// results screen). If the client is in tournament mode (tournament_flag is 1 in +// the StateFlags struct), then it will use this information to show the +// tournament match result screen before the battle results screen. -struct G_TournamentMatchInfo_GC_Ep3_6xB4x51 { - G_CardBattleCommandHeader header = {0xB4, sizeof(G_TournamentMatchInfo_GC_Ep3_6xB4x51) / 4, 0, 0x51, 0, 0, 0}; +struct G_TournamentMatchResult_GC_Ep3_6xB4x51 { + G_CardBattleCommandHeader header = {0xB4, sizeof(G_TournamentMatchResult_GC_Ep3_6xB4x51) / 4, 0, 0x51, 0, 0, 0}; ptext match_description; - struct Entry { + struct NamesEntry { ptext team_name; parray, 2> player_names; } __packed__; - parray teams; - le_uint16_t unknown_a1 = 0; - le_uint16_t unknown_a2 = 0; - le_uint16_t unknown_a3 = 0; - le_uint16_t unknown_a4 = 0; + parray names_entries; + struct ResultEntry { + le_uint16_t num_players; + le_uint16_t is_winner_team; + } __packed__; + parray result_entries; le_uint32_t meseta_amount = 0; // This field apparently is supposed to contain a %s token (as for printf) // that is replaced with meseta_amount. diff --git a/src/Episode3/DataIndex.cc b/src/Episode3/DataIndex.cc index bf2f82ce..124e34fc 100644 --- a/src/Episode3/DataIndex.cc +++ b/src/Episode3/DataIndex.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "../Loggers.hh" @@ -791,6 +792,15 @@ Rules::Rules() { this->clear(); } +void Rules::set_defaults() { + this->clear(); + this->overall_time_limit = 24; // 2 hours + this->phase_time_limit = 30; + this->min_dice = 1; + this->max_dice = 6; + this->char_hp = 15; +} + void Rules::clear() { this->overall_time_limit = 0; this->phase_time_limit = 0; @@ -1083,8 +1093,6 @@ string MapDefinition::str(const DataIndex* data_index) const { return join(lines, "\n"); } - - bool Rules::check_invalid_fields() const { Rules t = *this; return t.check_and_reset_invalid_fields(); @@ -1333,6 +1341,7 @@ DataIndex::DataIndex(const string& directory, uint32_t behavior_flags) if (!this->maps.emplace(entry->map.map_number, entry).second) { throw runtime_error("duplicate map number"); } + this->maps_by_name.emplace(entry->map.name, entry); string name = entry->map.name; static_game_data_log.info("Indexed Episode 3 map %s (%08" PRIX32 "; %s)", filename.c_str(), entry->map.map_number.load(), name.c_str()); @@ -1343,6 +1352,22 @@ DataIndex::DataIndex(const string& directory, uint32_t behavior_flags) filename.c_str(), e.what()); } } + + try { + auto json = JSONObject::parse(load_file(directory + "/com-decks.json")); + for (const auto& def_json : json->as_list()) { + auto& def = this->com_decks.emplace_back(new COMDeckDefinition()); + def->index = this->com_decks.size() - 1; + def->player_name = def_json->at(0)->as_string(); + def->deck_name = def_json->at(1)->as_string(); + auto card_ids_json = def_json->at(2)->as_list(); + for (size_t z = 0; z < 0x1F; z++) { + def->card_ids[z] = card_ids_json.at(z)->as_int(); + } + } + } catch (const exception& e) { + static_game_data_log.warning("Failed to load Episode 3 COM decks: %s", e.what()); + } } DataIndex::MapEntry::MapEntry(const MapDefinition& map) : map(map) { } @@ -1452,6 +1477,11 @@ shared_ptr DataIndex::definition_for_map_number(uint3 return this->maps.at(id); } +shared_ptr DataIndex::definition_for_map_name( + const string& name) const { + return this->maps_by_name.at(name); +} + set DataIndex::all_map_ids() const { set ret; for (const auto& it : this->maps) { @@ -1460,6 +1490,18 @@ set DataIndex::all_map_ids() const { return ret; } +size_t DataIndex::num_com_decks() const { + return this->com_decks.size(); +} + +shared_ptr DataIndex::com_deck(size_t which) const { + return this->com_decks.at(which); +} + +shared_ptr DataIndex::random_com_deck() const { + return this->com_decks[random_object() % this->com_decks.size()]; +} + } // namespace Episode3 diff --git a/src/Episode3/DataIndex.hh b/src/Episode3/DataIndex.hh index f8c34b04..9e52826c 100644 --- a/src/Episode3/DataIndex.hh +++ b/src/Episode3/DataIndex.hh @@ -642,6 +642,7 @@ struct Rules { bool operator==(const Rules& other) const; bool operator!=(const Rules& other) const; void clear(); + void set_defaults(); bool check_invalid_fields() const; bool check_and_reset_invalid_fields(); @@ -791,6 +792,15 @@ struct MapDefinition { // .mnmd format; also the format of (decompressed) quests +struct COMDeckDefinition { + size_t index; + std::string player_name; + std::string deck_name; + parray card_ids; +}; + + + class DataIndex { public: DataIndex(const std::string& directory, uint32_t behavior_flags); @@ -822,8 +832,14 @@ public: const std::string& get_compressed_map_list() const; std::shared_ptr definition_for_map_number(uint32_t id) const; + std::shared_ptr definition_for_map_name( + const std::string& name) const; std::set all_map_ids() const; + size_t num_com_decks() const; + std::shared_ptr com_deck(size_t which) const; + std::shared_ptr random_com_deck() const; + const uint32_t behavior_flags; private: @@ -837,6 +853,9 @@ private: // compressed map list at load time. mutable std::string compressed_map_list; std::map> maps; + std::unordered_map> maps_by_name; + + std::vector> com_decks; }; diff --git a/src/Episode3/PlayerState.cc b/src/Episode3/PlayerState.cc index 62b0eb2f..56a83b2e 100644 --- a/src/Episode3/PlayerState.cc +++ b/src/Episode3/PlayerState.cc @@ -43,8 +43,8 @@ PlayerState::PlayerState(uint8_t client_id, shared_ptr server) void PlayerState::init() { if (this->server()->player_states[this->client_id].get() != this) { - // TODO: The original code handles this, but we don't. Figure out if this is - // actually needed and implement it if so. + // Note: The original code handles this, but we don't. This appears not to + // ever happen, so we didn't bother implementing it. throw logic_error("replacing a player state object is not permitted"); } diff --git a/src/Episode3/Server.cc b/src/Episode3/Server.cc index 43dcdad4..1f8ee99c 100644 --- a/src/Episode3/Server.cc +++ b/src/Episode3/Server.cc @@ -34,10 +34,12 @@ void ServerBase::PresenceEntry::clear() { ServerBase::ServerBase( shared_ptr lobby, shared_ptr data_index, - uint32_t random_seed) + uint32_t random_seed, + bool is_tournament) : lobby(lobby), data_index(data_index), - random_seed(random_seed) { } + random_seed(random_seed), + is_tournament(is_tournament) { } void ServerBase::init() { this->reset(); @@ -96,7 +98,7 @@ Server::Server(shared_ptr base) team_num_ally_fcs_destroyed(0), team_num_cards_destroyed(0), hard_reset_flag(false), - tournament_flag(0), + tournament_flag(base->is_tournament ? 1 : 0), num_trap_tiles_of_type(0), chosen_trap_tile_index_of_type(0), has_done_pb(0), @@ -142,6 +144,39 @@ shared_ptr Server::base() const { return s; } +int8_t Server::get_winner_team_id() const { + parray team_player_counts(0); + parray team_win_flag_counts(0); + for (size_t client_id = 0; client_id < 4; client_id++) { + auto ps = this->player_states[client_id]; + if (!ps) { + continue; + } + uint8_t team_id = ps->get_team_id(); + team_player_counts[team_id]++; + if (ps->assist_flags & 4) { + team_win_flag_counts[team_id]++; + } + } + + if (!team_player_counts[0] || !team_player_counts[1]) { + throw logic_error("at least one team has no players"); + } + if (team_win_flag_counts[0] && team_win_flag_counts[1]) { + throw logic_error("both teams have winning players"); + } + for (int8_t z = 0; z < 2; z++) { + if (!team_win_flag_counts[z]) { + continue; + } + if (team_win_flag_counts[z] != team_player_counts[z]) { + throw logic_error("only some players on team 0 have won"); + } + return z; + } + return -1; // No team has won (yet) +} + void Server::send(const void* data, size_t size) const { auto l = this->base()->lobby.lock(); if (!l) { @@ -418,7 +453,7 @@ bool Server::check_for_battle_end() { } } else { // Both teams defeated?? I guess this is technically possible ret = true; - this->unknown_8023D4E0(0x4000); + this->compute_losing_team_id_and_add_winner_flags(0x4000); } } else { // Not DEFEAT_TEAM @@ -449,7 +484,7 @@ bool Server::check_for_battle_end() { } } else { ret = true; - this->unknown_8023D4E0(0x4000); + this->compute_losing_team_id_and_add_winner_flags(0x4000); } } @@ -649,7 +684,7 @@ void Server::draw_phase_after() { } } if (unknown_v1) { - this->unknown_8023D4E0(0); + this->compute_losing_team_id_and_add_winner_flags(0); } this->round_num--; this->set_battle_ended(); @@ -2136,7 +2171,7 @@ void Server::handle_6xB3x49_card_counts(const string& data) { decrypt_trivial_gci_data(dest_counts.data(), dest_counts.bytes(), in_cmd.basis); } -void Server::unknown_8023D4E0(uint32_t flags) { +void Server::compute_losing_team_id_and_add_winner_flags(uint32_t flags) { for (size_t z = 0; z < 4; z++) { auto ps = this->player_states[z]; if (ps) { @@ -2146,8 +2181,8 @@ void Server::unknown_8023D4E0(uint32_t flags) { uint32_t flags_to_add = flags | 0x804; - // First, check which team has fewer surviving SCs - int8_t team_id = -1; + // First, check which team has more dead SCs + int8_t losing_team_id = -1; uint32_t team_counts[2] = {0, 0}; for (size_t z = 0; z < 4; z++) { auto ps = this->player_states[z]; @@ -2160,13 +2195,13 @@ void Server::unknown_8023D4E0(uint32_t flags) { } } if (team_counts[1] < team_counts[0]) { - team_id = 0; + losing_team_id = 0; } else if (team_counts[0] < team_counts[1]) { - team_id = 1; + losing_team_id = 1; } // If the SC counts match, break ties by remaining SC HP - if (team_id == -1) { + if (losing_team_id == -1) { team_counts[0] = 0; team_counts[1] = 0; for (size_t z = 0; z < 4; z++) { @@ -2180,14 +2215,14 @@ void Server::unknown_8023D4E0(uint32_t flags) { } } if (team_counts[0] < team_counts[1]) { - team_id = 0; + losing_team_id = 0; } else if (team_counts[1] < team_counts[0]) { - team_id = 1; + losing_team_id = 1; } } // If still tied, break ties by number of opponent cards destroyed - if (team_id == -1) { + if (losing_team_id == -1) { team_counts[0] = 0; team_counts[1] = 0; for (size_t z = 0; z < 4; z++) { @@ -2198,14 +2233,14 @@ void Server::unknown_8023D4E0(uint32_t flags) { team_counts[ps->get_team_id()] += ps->stats.num_opponent_cards_destroyed; } if (team_counts[0] < team_counts[1]) { - team_id = 0; + losing_team_id = 0; } else if (team_counts[1] < team_counts[0]) { - team_id = 1; + losing_team_id = 1; } } // If still tied, break ties by amount of damage given - if (team_id == -1) { + if (losing_team_id == -1) { team_counts[0] = 0; team_counts[1] = 0; for (size_t z = 0; z < 4; z++) { @@ -2216,15 +2251,15 @@ void Server::unknown_8023D4E0(uint32_t flags) { team_counts[ps->get_team_id()] += ps->stats.damage_given; } if (team_counts[0] < team_counts[1]) { - team_id = 0; + losing_team_id = 0; } else if (team_counts[1] < team_counts[0]) { - team_id = 1; + losing_team_id = 1; } } // If STILL tied, roll dice and arbitrarily make one team the winner - if (team_id == -1) { - while (team_id == -1) { + if (losing_team_id == -1) { + while (losing_team_id == -1) { team_counts[1] = 0; team_counts[0] = 0; for (size_t z = 0; z < 4; z++) { @@ -2237,9 +2272,9 @@ void Server::unknown_8023D4E0(uint32_t flags) { team_counts[0] *= this->team_client_count[1]; team_counts[1] *= this->team_client_count[0]; if (team_counts[0] < team_counts[1]) { - team_id = 0; + losing_team_id = 0; } else if (team_counts[1] < team_counts[0]) { - team_id = 1; + losing_team_id = 1; } } flags_to_add = flags | 0x1004; @@ -2250,7 +2285,7 @@ void Server::unknown_8023D4E0(uint32_t flags) { if (!ps) { continue; } - if (team_id != ps->get_team_id()) { + if (losing_team_id != ps->get_team_id()) { ps->assist_flags |= flags_to_add; } ps->update_hand_and_equip_state_and_send_6xB4x02_if_needed(); diff --git a/src/Episode3/Server.hh b/src/Episode3/Server.hh index d5977060..06c8fa29 100644 --- a/src/Episode3/Server.hh +++ b/src/Episode3/Server.hh @@ -58,7 +58,8 @@ public: ServerBase( std::shared_ptr lobby, std::shared_ptr data_index, - uint32_t random_seed); + uint32_t random_seed, + bool is_tournament); void init(); void reset(); void recreate_server(); @@ -74,6 +75,7 @@ public: std::weak_ptr lobby; std::shared_ptr data_index; uint32_t random_seed; + bool is_tournament; std::shared_ptr map_and_rules1; std::shared_ptr map_and_rules2; @@ -94,6 +96,8 @@ public: std::shared_ptr base(); std::shared_ptr base() const; + int8_t get_winner_team_id() const; + template void send(const T& cmd) const { if (cmd.header.size != sizeof(cmd) / 4) { @@ -200,7 +204,7 @@ public: void handle_6xB3x41_map_request(const std::string& data); void handle_6xB3x48_end_turn(const std::string& data); void handle_6xB3x49_card_counts(const std::string& data); - void unknown_8023D4E0(uint32_t flags); + void compute_losing_team_id_and_add_winner_flags(uint32_t flags); uint32_t get_team_exp(uint8_t team_id) const; uint32_t send_6xB4x06_if_card_ref_invalid( uint16_t card_ref, int16_t negative_value); diff --git a/src/Episode3/Tournament.cc b/src/Episode3/Tournament.cc new file mode 100644 index 00000000..90ba36c1 --- /dev/null +++ b/src/Episode3/Tournament.cc @@ -0,0 +1,449 @@ +#include "Tournament.hh" + +#include + +#include "../CommandFormats.hh" +#include "../SendCommands.hh" + +using namespace std; + +namespace Episode3 { + + + +Tournament::Team::Team( + shared_ptr tournament, size_t index, size_t max_players) + : tournament(tournament), + index(index), + max_players(max_players), + name(""), + password(""), + num_rounds_cleared(0), + is_active(true) { } + +string Tournament::Team::str() const { + string ret = string_printf("[Team/%zu %s %zu/%zuP name=%s pass=%s rounds=%zu", + this->index, this->is_active ? "active" : "inactive", + this->player_serial_numbers.size(), this->max_players, this->name.c_str(), + this->password.c_str(), this->num_rounds_cleared); + for (uint32_t serial_number : this->player_serial_numbers) { + ret += string_printf(" %08" PRIX32, serial_number); + } + return ret + "]"; +} + +void Tournament::Team::register_player( + uint32_t serial_number, + const string& team_name, + const string& password) { + if (this->player_serial_numbers.size() >= this->max_players) { + throw runtime_error("team is full"); + } + + if (!this->name.empty() && (password != this->password)) { + throw runtime_error("incorrect password"); + } + + auto tournament = this->tournament.lock(); + if (!tournament) { + throw runtime_error("tournament has been deleted"); + } + if (!tournament->all_player_serial_numbers.emplace(serial_number).second) { + throw runtime_error("player already registered in same tournament"); + } + + if (!this->player_serial_numbers.emplace(serial_number).second) { + throw logic_error("player already registered in team but not in tournament"); + } + + if (this->name.empty()) { + this->name = team_name; + this->password = password; + } +} + +bool Tournament::Team::unregister_player(uint32_t serial_number) { + if (this->player_serial_numbers.erase(serial_number)) { + if (this->player_serial_numbers.empty()) { + this->name.clear(); + this->password.clear(); + } + + auto tournament = this->tournament.lock(); + if (!tournament) { + return false; + } + + // If the tournament has already started, make the team forfeit their game. + // If any player withdraws from a team after the registration phase, the + // entire team essentially forfeits their entry. + if (tournament->get_state() != Tournament::State::REGISTRATION) { + // Look through the pending matches to see if this team is involved in any + // of them + for (auto match : tournament->pending_matches) { + if (!match->preceding_a || !match->preceding_b) { + throw logic_error("zero-round match is pending after tournament registration phase"); + } + if (match->preceding_a->winner_team.get() == this) { + match->set_winner_team(match->preceding_b->winner_team); + break; + } else if (match->preceding_b->winner_team.get() == this) { + match->set_winner_team(match->preceding_a->winner_team); + break; + } + } + + // If the tournament has not started yet, just remove the player from the + // team + } else { + if (!tournament->all_player_serial_numbers.erase(serial_number)) { + throw logic_error("player removed from team but not from tournament"); + } + } + + return true; + + } else { + return false; + } +} + + + +Tournament::Match::Match( + shared_ptr tournament, + shared_ptr preceding_a, + shared_ptr preceding_b) + : tournament(tournament), + preceding_a(preceding_a), + preceding_b(preceding_b), + winner_team(nullptr), + round_num(0) { + if (this->preceding_a->round_num != this->preceding_b->round_num) { + throw logic_error("preceding matches have different round numbers"); + } + this->round_num = this->preceding_a->round_num; +} + +Tournament::Match::Match( + shared_ptr tournament, + shared_ptr winner_team) + : tournament(tournament), + preceding_a(nullptr), + preceding_b(nullptr), + winner_team(winner_team), + round_num(0) { } + +string Tournament::Match::str() const { + string winner_str = this->winner_team ? this->winner_team->str() : "(none)"; + return "[Match winner=" + winner_str + "]"; +} + +bool Tournament::Match::resolve_if_no_players() { + // If both matches before this one are resolved and neither winner team has + // any humans on it, skip this match entirely and just make one team advance + // arbitrarily + if (!this->winner_team && + this->preceding_a->winner_team && + this->preceding_b->winner_team && + this->preceding_a->winner_team->player_serial_numbers.empty() && + this->preceding_b->winner_team->player_serial_numbers.empty()) { + this->set_winner_team((random_object() & 1) + ? this->preceding_b->winner_team : this->preceding_a->winner_team); + return true; + } else { + return false; + } +} + +void Tournament::Match::resolve_following_matches() { + auto tournament = this->tournament.lock(); + if (!tournament) { + return; + } + + tournament->pending_matches.erase(this->shared_from_this()); + + // Resolve all matches up the chain until we can't anymore (this + // automatically skips CPU-only matches) + auto following = this->following.lock(); + while (following && following->resolve_if_no_players()) { + tournament->pending_matches.erase(following); + following = following->following.lock(); + } + + // If there's a following match that wasn't resolved, mark it pending + if (following) { + tournament->pending_matches.emplace(following); + } + + // If there are no pending matches, then the tournament is complete + if (tournament->pending_matches.empty()) { + tournament->current_state = Tournament::State::COMPLETE; + } +} + +void Tournament::Match::set_winner_team(shared_ptr team) { + if (!this->preceding_a || !this->preceding_b) { + throw logic_error("set_winner_team called on zero-round match"); + } + if ((team != this->preceding_a->winner_team) && + (team != this->preceding_b->winner_team)) { + throw logic_error("winner team did not participate in match"); + } + + this->winner_team = team; + + this->winner_team->num_rounds_cleared++; + if (this->winner_team == this->preceding_a->winner_team) { + this->preceding_b->winner_team->is_active = false; + } else { + this->preceding_a->winner_team->is_active = false; + } + + this->resolve_following_matches(); +} + +shared_ptr Tournament::Match::opponent_team_for_team( + shared_ptr team) const { + if (!this->preceding_a || !this->preceding_b) { + throw logic_error("zero-round matches do not have opponents"); + } + if (team == this->preceding_a->winner_team) { + return this->preceding_b->winner_team; + } else if (team == this->preceding_b->winner_team) { + return this->preceding_a->winner_team; + } else { + throw logic_error("team is not registered for this match"); + } +} + + + +Tournament::Tournament( + shared_ptr data_index, + uint8_t number, + const string& name, + shared_ptr map, + const Rules& rules, + size_t num_teams, + bool is_2v2) + : log(string_printf("[Tournament/%02hhX] ", number)), + data_index(data_index), + number(number), + name(name), + map(map), + rules(rules), + num_teams(num_teams), + is_2v2(is_2v2), + current_state(State::REGISTRATION) { + if (this->num_teams < 4) { + throw invalid_argument("team count must be 4 or more"); + } + if (this->num_teams > 32) { + throw invalid_argument("team count must be 32 or fewer"); + } + if (this->num_teams & (this->num_teams - 1)) { + throw invalid_argument("team count must be a power of 2"); + } +} + +void Tournament::init() { + // Create all the teams and initial matches + while (this->teams.size() < this->num_teams) { + auto t = make_shared( + this->shared_from_this(), this->teams.size(), this->is_2v2 ? 2 : 1); + this->teams.emplace_back(t); + this->zero_round_matches.emplace_back(make_shared( + this->shared_from_this(), t)); + } + + // Make all the zero round matches pending (this is needed so that start() + // will auto-resolve all-CPU matches in the first round) + for (auto m : this->zero_round_matches) { + this->pending_matches.emplace(m); + } + + // Create the bracket matches + vector> current_round_matches = this->zero_round_matches; + while (current_round_matches.size() > 1) { + vector> next_round_matches; + for (size_t z = 0; z < current_round_matches.size(); z += 2) { + auto m = make_shared( + this->shared_from_this(), + current_round_matches[z], + current_round_matches[z + 1]); + current_round_matches[z]->following = m; + current_round_matches[z + 1]->following = m; + next_round_matches.emplace_back(move(m)); + } + current_round_matches = move(next_round_matches); + } + this->final_match = current_round_matches.at(0); +} + +std::shared_ptr Tournament::get_data_index() const { + return this->data_index; +} + +uint8_t Tournament::get_number() const { + return this->number; +} + +const string& Tournament::get_name() const { + return this->name; +} + +shared_ptr Tournament::get_map() const { + return this->map; +} + +const Rules& Tournament::get_rules() const { + return this->rules; +} + +bool Tournament::get_is_2v2() const { + return this->is_2v2; +} + +Tournament::State Tournament::get_state() const { + return this->current_state; +} + +const vector>& Tournament::all_teams() const { + return this->teams; +} + +shared_ptr Tournament::get_team(size_t index) const { + return this->teams.at(index); +} + +shared_ptr Tournament::get_winner_team() const { + if (this->current_state != State::COMPLETE) { + return nullptr; + } + if (!this->final_match->winner_team) { + throw logic_error("tournament is complete but winner is not set"); + } + return this->final_match->winner_team; +} + +shared_ptr Tournament::next_match_for_team( + shared_ptr team) const { + if (this->current_state == Tournament::State::REGISTRATION) { + return nullptr; + } + for (auto match : this->pending_matches) { + if (!match->preceding_a || !match->preceding_b) { + throw logic_error("zero-round match is pending after tournament registration phase"); + } + if ((team == match->preceding_a->winner_team) || + (team == match->preceding_b->winner_team)) { + return match; + } + } + return nullptr; +} + +void Tournament::start() { + if (this->current_state != State::REGISTRATION) { + throw runtime_error("tournament has already started"); + } + + this->current_state = State::IN_PROGRESS; + + // Assign names to COM teams, and assign COM decks to all empty slots + for (size_t z = 0; z < this->zero_round_matches.size(); z++) { + auto m = this->zero_round_matches[z]; + auto t = m->winner_team; + if (t->name.empty()) { + t->name = string_printf("COM:%zu", z); + } + if (this->data_index->num_com_decks() < t->max_players - t->player_serial_numbers.size()) { + throw runtime_error("not enough COM decks to complete team"); + } + while (t->player_serial_numbers.size() + t->com_decks.size() < t->max_players) { + t->com_decks.emplace(this->data_index->random_com_deck()); + } + } + + // Resolve all possible CPU-only matches + for (auto m : this->zero_round_matches) { + m->resolve_following_matches(); + } +} + +void Tournament::print_bracket(FILE* stream) const { + function, size_t)> print_match = [&](shared_ptr m, size_t indent_level) -> void { + for (size_t z = 0; z < indent_level; z++) { + fputc(' ', stream); + fputc(' ', stream); + } + string match_str = m->str(); + fprintf(stream, "%s\n", match_str.c_str()); + if (m->preceding_a) { + print_match(m->preceding_a, indent_level + 1); + } + if (m->preceding_b) { + print_match(m->preceding_b, indent_level + 1); + } + }; + print_match(this->final_match, 0); +} + + + +vector> TournamentIndex::all_tournaments() const { + vector> ret; + for (size_t z = 0; z < this->tournaments.size(); z++) { + if (this->tournaments[z]) { + ret.emplace_back(this->tournaments[z]); + } + } + return ret; +} + +shared_ptr TournamentIndex::create_tournament( + shared_ptr data_index, + const string& name, + shared_ptr map, + const Rules& rules, + size_t num_teams, + bool is_2v2) { + // Find an unused tournament number + uint8_t number; + for (number = 0; number < this->tournaments.size(); number++) { + if (!this->tournaments[number]) { + break; + } + } + if (number >= this->tournaments.size()) { + throw runtime_error("all tournament slots are full"); + } + + auto t = make_shared(data_index, number, name, map, rules, num_teams, is_2v2); + t->init(); + this->tournaments[number] = t; + return t; +} + +void TournamentIndex::delete_tournament(uint8_t number) { + this->tournaments[number].reset(); +} + +shared_ptr TournamentIndex::get_tournament(uint8_t number) const { + return this->tournaments[number]; +} + +shared_ptr TournamentIndex::get_tournament(const string& name) const { + for (size_t z = 0; z < this->tournaments.size(); z++) { + if (this->tournaments[z] && (this->tournaments[z]->get_name() == name)) { + return this->tournaments[z]; + } + } + return nullptr; +} + + + +} // namespace Episode3 diff --git a/src/Episode3/Tournament.hh b/src/Episode3/Tournament.hh new file mode 100644 index 00000000..6bd6f8cc --- /dev/null +++ b/src/Episode3/Tournament.hh @@ -0,0 +1,164 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "../Player.hh" + +struct Lobby; + +namespace Episode3 { + +// The comment in Server.hh does not apply to this file (and Tournament.cc). + + + +// TODO: We should build a way to save tournament state to a file, so it can +// persist across server restarts. + +class Tournament : public std::enable_shared_from_this { +public: + enum class State { + REGISTRATION = 0, + IN_PROGRESS, + COMPLETE, + }; + + struct Team : public std::enable_shared_from_this { + std::weak_ptr tournament; + size_t index; + size_t max_players; + std::set player_serial_numbers; + std::set> com_decks; + std::string name; + std::string password; + size_t num_rounds_cleared; + bool is_active; + + Team( + std::shared_ptr tournament, + size_t index, + size_t max_players); + std::string str() const; + + void register_player( + uint32_t serial_number, + const std::string& team_name, + const std::string& password); + bool unregister_player(uint32_t serial_number); + }; + + struct Match : public std::enable_shared_from_this { + enum class WinnerTeam { + A = 0, + B = 1, + }; + std::weak_ptr tournament; + std::shared_ptr preceding_a; + std::shared_ptr preceding_b; + std::weak_ptr following; + std::shared_ptr winner_team; + size_t round_num; + + Match( + std::shared_ptr tournament, + std::shared_ptr preceding_a, + std::shared_ptr preceding_b); + Match( + std::shared_ptr tournament, + std::shared_ptr winner_team); + std::string str() const; + + bool resolve_if_no_players(); + void resolve_following_matches(); + void set_winner_team(std::shared_ptr team); + std::shared_ptr opponent_team_for_team(std::shared_ptr team) const; + }; + + Tournament( + std::shared_ptr data_index, + uint8_t number, + const std::string& name, + std::shared_ptr map, + const Rules& rules, + size_t num_teams, + bool is_2v2); + ~Tournament() = default; + void init(); + + std::shared_ptr get_data_index() const; + uint8_t get_number() const; + const std::string& get_name() const; + std::shared_ptr get_map() const; + const Rules& get_rules() const; + bool get_is_2v2() const; + State get_state() const; + + const std::vector>& all_teams() const; + std::shared_ptr get_team(size_t index) const; + std::shared_ptr get_winner_team() const; + std::shared_ptr next_match_for_team(std::shared_ptr team) const; + void start(); + + void print_bracket(FILE* stream) const; + void print_bracket_stderr() const; + +private: + PrefixedLogger log; + + std::shared_ptr data_index; + uint8_t number; + std::string name; + std::shared_ptr map; + Rules rules; + size_t num_teams; + bool is_2v2; + State current_state; + + std::set all_player_serial_numbers; + std::unordered_set> pending_matches; + + // This vector contains all teams in the original starting order of the + // tournament (that is, all teams in the first round). The order within this + // vector determines which team will play against which other team in the + // first round: [0] will play against [1], [2] will play against [3], etc. + std::vector> teams; + // The tournament begins with a "zero round", in which each team automatically + // "wins" a match, putting them into the first round. This is just to make the + // data model easier to manage, so we don't have to have a type of match with + // no preceding round. + std::vector> zero_round_matches; + std::shared_ptr final_match; +}; + +class TournamentIndex { +public: + TournamentIndex() = default; + ~TournamentIndex() = default; + + std::vector> all_tournaments() const; + + std::shared_ptr create_tournament( + std::shared_ptr data_index, + const std::string& name, + std::shared_ptr map, + const Rules& rules, + size_t num_teams, + bool is_2v2); + void delete_tournament(uint8_t number); + std::shared_ptr get_tournament(uint8_t number) const; + std::shared_ptr get_tournament(const std::string& name) const; + +private: + parray, 0x20> tournaments; +}; + + + +} // namespace Episode3 diff --git a/src/Lobby.hh b/src/Lobby.hh index 154bf49b..43a4baa6 100644 --- a/src/Lobby.hh +++ b/src/Lobby.hh @@ -95,6 +95,7 @@ struct Lobby { std::shared_ptr battle_record; // Not used in watcher games std::shared_ptr prev_battle_record; // Only used in primary games std::shared_ptr battle_player; // Only used in replay games + std::shared_ptr tournament_match; // Lobby stuff uint8_t event; diff --git a/src/Menu.hh b/src/Menu.hh index 7b2568a4..3154af72 100644 --- a/src/Menu.hh +++ b/src/Menu.hh @@ -23,6 +23,8 @@ namespace MenuID { constexpr uint32_t PROGRAMS = 0x88000088; constexpr uint32_t PATCHES = 0x99000099; constexpr uint32_t PROXY_OPTIONS = 0xAA0000AA; + constexpr uint32_t TOURNAMENTS = 0xBB0000BB; + constexpr uint32_t TOURNAMENT_ENTRIES = 0xCC0000CC; } namespace MainMenuItemID { diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index cfd77739..bf0b78ab 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -20,11 +20,17 @@ #include "SendCommands.hh" #include "StaticGameData.hh" #include "Text.hh" +#include "Episode3/Tournament.hh" using namespace std; +const char* QUEST_BARRIER_DISCONNECT_HOOK_NAME = "quest_barrier"; +const char* CARD_AUCTION_DISCONNECT_HOOK_NAME = "card_auction"; + + + vector quest_categories_menu({ MenuItem(static_cast(QuestCategory::RETRIEVAL), u"Retrieval", u"$E$C6Quests that involve\nretrieving an object", 0), MenuItem(static_cast(QuestCategory::EXTERMINATION), u"Extermination", u"$E$C6Quests that involve\ndestroying all\nmonsters", 0), @@ -861,21 +867,92 @@ static void on_ep3_battle_table_state(shared_ptr s, shared_ptr c, uint16_t, uint32_t flag, const string& data) { // E4 const auto& cmd = check_size_t(data); auto l = s->find_lobby(c->lobby_id); - if (l->is_game() || !(l->flags & Lobby::Flag::EPISODE_3_ONLY)) { - throw runtime_error("battle table command sent in non-CARD lobby"); - } if (flag) { + if (l->is_game() || !(l->flags & Lobby::Flag::EPISODE_3_ONLY)) { + throw runtime_error("battle table join command sent in non-CARD lobby"); + } c->card_battle_table_number = cmd.table_number; c->card_battle_table_seat_number = cmd.seat_number; - } else { + + auto team = c->ep3_tournament_team.lock(); + if (team) { + auto tourn = team->tournament.lock(); + if (tourn) { + auto match = tourn->next_match_for_team(team); + if (match) { + auto other_team = match->opponent_team_for_team(team); + unordered_set required_serial_numbers; + for (uint32_t serial_number : team->player_serial_numbers) { + required_serial_numbers.emplace(serial_number); + } + for (uint32_t serial_number : other_team->player_serial_numbers) { + required_serial_numbers.emplace(serial_number); + } + unordered_set> game_clients; + for (const auto& other_c : l->clients) { + if (!other_c) { + continue; + } + if ((other_c->card_battle_table_number == cmd.table_number) && + required_serial_numbers.erase(other_c->license->serial_number)) { + game_clients.emplace(other_c); + } + } + if (required_serial_numbers.empty()) { + for (const auto& other_c : l->clients) { + if (other_c && (other_c->card_battle_table_number == cmd.table_number)) { + other_c->card_battle_table_number = -1; + other_c->card_battle_table_seat_number = 0; + } + } + + G_SetStateFlags_GC_Ep3_6xB4x03 state_cmd; + state_cmd.state.turn_num = 1; + state_cmd.state.battle_phase = Episode3::BattlePhase::INVALID_00; + state_cmd.state.current_team_turn1 = 0xFF; + state_cmd.state.current_team_turn2 = 0xFF; + state_cmd.state.action_subphase = Episode3::ActionSubphase::ATTACK; + state_cmd.state.setup_phase = Episode3::SetupPhase::REGISTRATION; + state_cmd.state.registration_phase = Episode3::RegistrationPhase::AWAITING_NUM_PLAYERS; + state_cmd.state.team_exp.clear(0); + state_cmd.state.team_dice_boost.clear(0); + state_cmd.state.first_team_turn = 0xFF; + state_cmd.state.tournament_flag = 0x01; + state_cmd.state.client_sc_card_types.clear(Episode3::CardType::INVALID_FF); + + // TODO: We don't know if this works with multiple players. Test it. + uint32_t flags = Lobby::Flag::NON_V1_ONLY | Lobby::Flag::EPISODE_3_ONLY; + auto game = create_game_generic(s, c, u"", u"", 0xFF, 0, flags); + game->tournament_match = match; + for (auto game_c : game_clients) { + send_command_t(game_c, 0x60, 0x00, state_cmd); + s->change_client_lobby(game_c, game, false); + send_join_lobby(game_c, game); + game_c->flags |= Client::Flag::LOADING; + } + } + } + } + } + + } else { // Leaving battle table c->card_battle_table_number = -1; c->card_battle_table_seat_number = 0; } + send_ep3_card_battle_table_state(l, c->card_battle_table_number); - // TODO: If a client disconnects while at the battle table, we need to send - // a table update to all the other clients at the table (if any). Use a - // disconnect hook for this. + + bool should_have_disconnect_hook = (c->card_battle_table_number != -1); + + const char* DISCONNECT_HOOK_NAME = "battle_table_state"; + if (should_have_disconnect_hook && !c->disconnect_hooks.count(DISCONNECT_HOOK_NAME)) { + c->disconnect_hooks.emplace(DISCONNECT_HOOK_NAME, [l, c]() -> void { + send_ep3_card_battle_table_state(l, c->card_battle_table_number); + }); + } else if (!should_have_disconnect_hook) { + c->disconnect_hooks.erase(DISCONNECT_HOOK_NAME); + } } static void on_ep3_battle_table_confirm(shared_ptr s, @@ -888,7 +965,7 @@ static void on_ep3_battle_table_confirm(shared_ptr s, if (flag) { // TODO - send_lobby_message_box(c, u"CARD battles are\nnot yet supported."); + send_lobby_message_box(c, u"Battle Tables are\nnot yet supported."); } } @@ -898,6 +975,15 @@ static void on_ep3_counter_state(shared_ptr s, shared_ptr c auto l = s->find_lobby(c->lobby_id); if (flag != 0) { send_command(c, 0xDC, 0x00); + if (l->tournament_match) { + auto tourn = l->tournament_match->tournament.lock(); + if (tourn) { + send_ep3_set_tournament_player_decks(l, c, l->tournament_match); + string data = Episode3::Server::prepare_6xB6x41_map_definition( + tourn->get_map()); + c->channel.send(0x6C, 0x00, data); + } + } l->flags |= Lobby::Flag::BATTLE_IN_PROGRESS; } else { l->flags &= ~Lobby::Flag::BATTLE_IN_PROGRESS; @@ -924,7 +1010,7 @@ static void on_ep3_server_data_request(shared_ptr s, shared_ptrlog.info("Recreating Episode 3 server state"); } l->ep3_server_base = make_shared( - l, s->ep3_data_index, l->random_seed); + l, s->ep3_data_index, l->random_seed, l->tournament_match ? true : false); l->ep3_server_base->init(); if (s->ep3_behavior_flags & Episode3::BehaviorFlag::ENABLE_STATUS_MESSAGES) { @@ -960,17 +1046,80 @@ static void on_ep3_server_data_request(shared_ptr s, shared_ptrep3_server_base->server->on_server_data_input(data); + if (l->ep3_server_base->server->battle_finished && l->tournament_match) { + int8_t winner_team_id = l->ep3_server_base->server->get_winner_team_id(); + if (winner_team_id == -1) { + throw runtime_error("match concluded, but winner team not specified"); + } + if (winner_team_id == 0) { + l->tournament_match->set_winner_team(l->tournament_match->preceding_a->winner_team); + } else if (winner_team_id == 1) { + l->tournament_match->set_winner_team(l->tournament_match->preceding_b->winner_team); + } else { + throw logic_error("invalid winner team id"); + } + send_ep3_tournament_match_result_result(l, l->tournament_match); + } } -static void on_ep3_tournament_control(shared_ptr, shared_ptr c, - uint16_t, uint32_t, const string&) { // E2 - // The client will set their interaction mode expecting a menu to be sent, but - // since we don't implement tournaments, they will get stuck here unless we - // send something. An 01 (lobby message box) seems to work fine. - send_lobby_message_box(c, u"$C6Tournaments are\nnot supported."); +static void on_tournament_complete( + shared_ptr s, shared_ptr tourn) { + auto team = tourn->get_winner_team(); + if (team->player_serial_numbers.empty()) { + send_ep3_text_message_printf(s, "$C7A CPU team won\nthe tournament\n$C6%s", tourn->get_name().c_str()); + } else { + send_ep3_text_message_printf(s, "$C6%s$C7\nwon the tournament\n$C6%s", team->name.c_str(), tourn->get_name().c_str()); + } + s->ep3_tournament_index->delete_tournament(tourn->get_number()); } - +static void on_ep3_tournament_control(shared_ptr s, shared_ptr c, + uint16_t, uint32_t flag, const string&) { // E2 + switch (flag) { + case 0x00: // Request tournament list + send_ep3_tournament_list(s, c); + break; + case 0x01: { // Check tournament + auto team = c->ep3_tournament_team.lock(); + if (team) { + auto tourn = team->tournament.lock(); + if (tourn) { + send_ep3_tournament_entry_list(c, tourn); + } else { + send_lobby_message_box(c, u"$C6The tournament\nhas concluded."); + } + } else { + send_lobby_message_box(c, u"$C6You are not\nregistered in a\ntournament."); + } + break; + } + case 0x02: { // Cancel tournament entry + auto team = c->ep3_tournament_team.lock(); + if (team) { + auto tourn = team->tournament.lock(); + if (tourn) { + if (tourn->get_state() != Episode3::Tournament::State::COMPLETE) { + team->unregister_player(c->license->serial_number); + if (tourn->get_state() == Episode3::Tournament::State::COMPLETE) { + on_tournament_complete(s, tourn); + } + } + c->ep3_tournament_team.reset(); + } + } + send_ep3_confirm_tournament_entry(s, c, nullptr); + break; + } + case 0x03: // Create tournament spectator team (get battle list) + send_lobby_message_box(c, u"$C6Not supported"); // TODO + break; + case 0x04: // Join tournament spectator team (get team list) + send_lobby_message_box(c, u"$C6Not supported"); // TODO + break; + default: + throw runtime_error("invalid tournament operation"); + } +} static void on_message_box_closed(shared_ptr s, shared_ptr c, uint16_t, uint32_t, const string& data) { // D6 @@ -1070,6 +1219,25 @@ static void on_menu_item_info_request(shared_ptr s, shared_ptris_game()) { send_ship_info(c, u"$C4Incorrect game ID"); + } else if ((c->flags & Client::Flag::IS_EPISODE_3) && + (game->flags & Lobby::Flag::EPISODE_3_ONLY)) { + S_GameInformation_GC_Ep3_E1 cmd; + cmd.game_name = encode_sjis(game->name); + size_t num_players = 0; + for (const auto& client : game->clients) { + if (client) { + auto player = client->game_data.player(); + cmd.entries[num_players].name = player->disp.name; + cmd.entries[num_players].description = string_printf( + "%s CLv%" PRIu32 " %c", + name_for_char_class(player->disp.char_class), + player->disp.level + 1, + char_for_language_code(player->inventory.language)); + num_players++; + } + } + send_command_t(c, 0xE1, 0x00, cmd); + } else { string info; for (size_t x = 0; x < game->max_clients; x++) { @@ -1163,8 +1331,55 @@ static void on_menu_item_info_request(shared_ptr s, shared_ptrflags & Client::Flag::IS_EPISODE_3)) { + send_ship_info(c, u"Incorrect menu ID"); + break; + } + auto tourn = s->ep3_tournament_index->get_tournament(cmd.item_id); + if (tourn) { + send_ep3_tournament_info(c, tourn); + } + break; + } + case MenuID::TOURNAMENT_ENTRIES: { + if (!(c->flags & Client::Flag::IS_EPISODE_3)) { + send_ship_info(c, u"Incorrect menu ID"); + break; + } + uint16_t tourn_num = cmd.item_id >> 16; + uint16_t team_index = cmd.item_id & 0xFFFF; + auto tourn = s->ep3_tournament_index->get_tournament(tourn_num); + if (tourn) { + auto team = tourn->get_team(team_index); + if (team) { + string message; + if (team->name.empty()) { + message = string_printf("$C7(Unnamed team)\n%zu/%zu players\n%zu wins\n%s", + team->player_serial_numbers.size(), + team->max_players, + team->num_rounds_cleared, + team->password.empty() ? "" : "Locked"); + } else { + message = string_printf("$C6%s$C7\n%zu/%zu players\n%zu wins\n%s", + team->name.c_str(), + team->player_serial_numbers.size(), + team->max_players, + team->num_rounds_cleared, + team->password.empty() ? "" : "Locked"); + } + send_ship_info(c, decode_sjis(message)); + } else { + send_ship_info(c, u"$C7No such team"); + } + } else { + send_ship_info(c, u"$C7No such tournament"); + } + break; + } + default: - send_ship_info(c, u"Incorrect menu ID."); + send_ship_info(c, u"Incorrect menu ID"); break; } } @@ -1175,14 +1390,23 @@ static void on_menu_selection(shared_ptr s, shared_ptr c, uint32_t menu_id; uint32_t item_id; + u16string team_name; u16string password; if (data.size() > sizeof(C_MenuSelection_10_Flag00)) { if (uses_unicode) { + // TODO: We can support the Flag03 variant here, but PC/BB probably never + // actually use it. const auto& cmd = check_size_t(data); password = cmd.password; menu_id = cmd.basic_cmd.menu_id; item_id = cmd.basic_cmd.item_id; + } else if (data.size() > sizeof(C_MenuSelection_DC_V3_10_Flag02)) { + const auto& cmd = check_size_t(data); + team_name = decode_sjis(cmd.unknown_a1); + password = decode_sjis(cmd.password); + menu_id = cmd.basic_cmd.menu_id; + item_id = cmd.basic_cmd.item_id; } else { const auto& cmd = check_size_t(data); password = decode_sjis(cmd.password); @@ -1503,6 +1727,9 @@ static void on_menu_selection(shared_ptr s, shared_ptr c, (l->clients[x]->version() != GameVersion::PC) && !(l->clients[x]->flags & Client::Flag::IS_TRIAL_EDITION)) { l->clients[x]->flags |= Client::Flag::LOADING_QUEST; + l->clients[x]->disconnect_hooks.emplace(QUEST_BARRIER_DISCONNECT_HOOK_NAME, [l]() -> void { + send_quest_barrier_if_all_clients_ready(l); + }); } } @@ -1560,8 +1787,64 @@ static void on_menu_selection(shared_ptr s, shared_ptr c, } break; + case MenuID::TOURNAMENTS: { + if (!(c->flags & Client::Flag::IS_EPISODE_3)) { + throw runtime_error("non-Episode 3 client attempted to join tournament"); + } + auto tourn = s->ep3_tournament_index->get_tournament(item_id); + if (tourn) { + send_ep3_tournament_entry_list(c, tourn); + } + break; + } + case MenuID::TOURNAMENT_ENTRIES: { + if (!(c->flags & Client::Flag::IS_EPISODE_3)) { + throw runtime_error("non-Episode 3 client attempted to join tournament"); + } + if (c->ep3_tournament_team.lock()) { + send_lobby_message_box(c, u"$C6You are registered\nin a different\ntournament already"); + break; + } + if (team_name.empty()) { + team_name = c->game_data.player()->disp.name; + team_name += decode_sjis(string_printf("/%" PRIX32, c->license->serial_number)); + } + uint16_t tourn_num = item_id >> 16; + uint16_t team_index = item_id & 0xFFFF; + auto tourn = s->ep3_tournament_index->get_tournament(tourn_num); + if (tourn) { + auto team = tourn->get_team(team_index); + if (team) { + try { + team->register_player( + c->license->serial_number, + encode_sjis(team_name), + encode_sjis(password)); + c->ep3_tournament_team = team; + send_ep3_confirm_tournament_entry(s, c, tourn); + string message = string_printf("$C7You are registered in $C6%s$C7.\n\ +\n\ +After registration ends, you can start your\n\ +first match by standing at any Battle Table in\n\ +the lobby along with your partner (if any) and\n\ +opponent(s).", tourn->get_name().c_str()); + send_ep3_timed_message_box(c->channel, 240, message.c_str()); + + } catch (const exception& e) { + string message = string_printf("Cannot join team:\n%s", e.what()); + send_lobby_message_box(c, decode_sjis(message)); + } + } else { + send_lobby_message_box(c, u"Team does not exist"); + } + } else { + send_lobby_message_box(c, u"Tournament does\nnot exist"); + } + break; + } + default: - send_message_box(c, u"Incorrect menu ID."); + send_message_box(c, u"Incorrect menu ID"); break; } } @@ -1571,7 +1854,7 @@ static void on_change_lobby(shared_ptr s, shared_ptr c, const auto& cmd = check_size_t(data); if (cmd.menu_id != MenuID::LOBBY) { - send_message_box(c, u"Incorrect menu ID."); + send_message_box(c, u"Incorrect menu ID"); return; } @@ -1745,11 +2028,6 @@ static void on_quest_barrier(shared_ptr s, shared_ptr c, uint16_t, uint32_t, const string& data) { // AC check_size_v(data.size(), 0); - auto l = s->find_lobby(c->lobby_id); - if (!l || !l->is_game()) { - return; - } - // If this client is NOT loading, they should not send an AC. Sending an AC to // a client that isn't waiting to start a quest will crash the client, so we // have to be careful not to do so. @@ -1758,23 +2036,7 @@ static void on_quest_barrier(shared_ptr s, shared_ptr c, } c->flags &= ~Client::Flag::LOADING_QUEST; - // Check if any client is still loading - // TODO: We need to handle clients disconnecting while loading. Probably - // on_client_disconnect needs to check for this case... - size_t x; - for (x = 0; x < l->max_clients; x++) { - if (!l->clients[x]) { - continue; - } - if (l->clients[x]->flags & Client::Flag::LOADING_QUEST) { - break; - } - } - - // If they're all done, start the quest - if (x == l->max_clients) { - send_command(l, 0xAC, 0x00); - } + send_quest_barrier_if_all_clients_ready(s->find_lobby(c->lobby_id)); } static void on_update_quest_statistics(shared_ptr s, @@ -2576,10 +2838,6 @@ shared_ptr create_game_generic( c->log.info("Loaded maps contain %zu entries overall", game->enemies.size()); } - - s->change_client_lobby(c, game); - c->flags |= Client::Flag::LOADING; - return game; } @@ -2594,7 +2852,9 @@ static void on_create_game_pc(shared_ptr s, shared_ptr c, if (cmd.challenge_mode) { flags |= Lobby::Flag::CHALLENGE_MODE; } - create_game_generic(s, c, cmd.name, cmd.password, 1, cmd.difficulty, flags); + auto game = create_game_generic(s, c, cmd.name, cmd.password, 1, cmd.difficulty, flags); + s->change_client_lobby(c, game); + c->flags |= Client::Flag::LOADING; } static void on_create_game_dc_v3(shared_ptr s, shared_ptr c, @@ -2634,8 +2894,10 @@ static void on_create_game_dc_v3(shared_ptr s, shared_ptr c flags |= Lobby::Flag::CHALLENGE_MODE; } } - create_game_generic( + auto game = create_game_generic( s, c, name.c_str(), password.c_str(), episode, cmd.difficulty, flags); + s->change_client_lobby(c, game); + c->flags |= Client::Flag::LOADING; } static void on_create_game_bb(shared_ptr s, shared_ptr c, @@ -2652,8 +2914,10 @@ static void on_create_game_bb(shared_ptr s, shared_ptr c, if (cmd.solo_mode) { flags |= Lobby::Flag::SOLO_MODE; } - create_game_generic( + auto game = create_game_generic( s, c, cmd.name, cmd.password, cmd.episode, cmd.difficulty, flags); + s->change_client_lobby(c, game); + c->flags |= Client::Flag::LOADING; } static void on_lobby_name_request(shared_ptr s, shared_ptr c, @@ -2928,64 +3192,10 @@ static void on_card_auction_join(shared_ptr s, shared_ptr c return; } c->flags |= Client::Flag::AWAITING_CARD_AUCTION; - - // Check if any client is still loading - // TODO: We need to handle clients disconnecting during this procedure. - // Probably on_client_disconnect needs to check for this case... - size_t x; - for (x = 0; x < l->max_clients; x++) { - if (!l->clients[x]) { - continue; - } - if (!(l->clients[x]->flags & Client::Flag::AWAITING_CARD_AUCTION)) { - break; - } - } - if (x != l->max_clients) { - return; - } - - for (x = 0; x < l->max_clients; x++) { - if (l->clients[x]) { - l->clients[x]->flags &= ~Client::Flag::AWAITING_CARD_AUCTION; - } - } - - if ((s->ep3_card_auction_points == 0) || - (s->ep3_card_auction_min_size == 0) || - (s->ep3_card_auction_max_size == 0)) { - throw runtime_error("card auctions are not configured on this server"); - } - - uint16_t num_cards; - if (s->ep3_card_auction_min_size == s->ep3_card_auction_max_size) { - num_cards = s->ep3_card_auction_min_size; - } else { - num_cards = s->ep3_card_auction_min_size + - (random_object() % (s->ep3_card_auction_max_size - s->ep3_card_auction_min_size + 1)); - } - num_cards = min(num_cards, 0x14); - - uint64_t distribution_size = 0; - for (const auto& it : s->ep3_card_auction_pool) { - distribution_size += it.second.first; - } - - S_StartCardAuction_GC_Ep3_EF cmd; - cmd.points_available = s->ep3_card_auction_points; - for (size_t z = 0; z < num_cards; z++) { - uint64_t v = random_object() % distribution_size; - for (const auto& it : s->ep3_card_auction_pool) { - if (v >= it.second.first) { - v -= it.second.first; - } else { - cmd.entries[z].card_id = s->ep3_data_index->definition_for_card_name(it.first)->def.card_id.load(); - cmd.entries[z].min_price = it.second.second; - break; - } - } - } - send_command_t(l, 0xEF, num_cards, cmd); + c->disconnect_hooks.emplace(CARD_AUCTION_DISCONNECT_HOOK_NAME, [s, l]() -> void { + send_card_auction_if_all_clients_ready(s, l); + }); + send_card_auction_if_all_clients_ready(s, l); } diff --git a/src/SendCommands.cc b/src/SendCommands.cc index fb455d72..6d38cb34 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -21,6 +21,9 @@ using namespace std; +extern const char* QUEST_BARRIER_DISCONNECT_HOOK_NAME; +extern const char* CARD_AUCTION_DISCONNECT_HOOK_NAME; + const unordered_set v2_crypt_initial_client_commands({ 0x00260088, // (17) DCNTE license check 0x00B0008B, // (02) DCNTE login @@ -284,11 +287,6 @@ void send_quest_open_file_t( void send_quest_buffer_overflow( shared_ptr s, shared_ptr c) { - // TODO: Figure out a way to share this state across sessions. Maybe we could - // e.g. modify send_1D to send a nonzero flag value, which we could use to - // know that the client already has this patch? Or just add another command in - // the login sequence? - // PSO Episode 3 USA doesn't natively support the B2 command, but we can add // it back to the game with some tricky commands. For details on how this // works, see system/ppc/Episode3USAQuestBufferOverflow.s. @@ -314,8 +312,8 @@ void send_quest_buffer_overflow( void send_function_call( shared_ptr c, shared_ptr code, - const std::unordered_map& label_writes, - const std::string& suffix, + const unordered_map& label_writes, + const string& suffix, uint32_t checksum_addr, uint32_t checksum_size) { return send_function_call( @@ -332,8 +330,8 @@ void send_function_call( Channel& ch, uint64_t client_flags, shared_ptr code, - const std::unordered_map& label_writes, - const std::string& suffix, + const unordered_map& label_writes, + const string& suffix, uint32_t checksum_addr, uint32_t checksum_size) { if (client_flags & Client::Flag::NO_SEND_FUNCTION_CALL) { @@ -649,6 +647,17 @@ void send_message_box(shared_ptr c, const u16string& text) { send_text(c->channel, command, text, true); } +void send_ep3_timed_message_box(Channel& ch, uint32_t frames, const string& message) { + StringWriter w; + w.put({frames}); + add_color(w, message.data(), message.size()); + w.put_u8(0); + while (w.size() & 3) { + w.put_u8(0); + } + ch.send(0xEA, 0x00, w.str()); +} + void send_lobby_name(shared_ptr c, const u16string& text) { send_text(c->channel, 0x8A, text, false); } @@ -670,7 +679,7 @@ void send_ship_info(Channel& ch, const u16string& text) { send_header_text(ch, 0x11, 0, text, true); } -void send_text_message(Channel& ch, const std::u16string& text) { +void send_text_message(Channel& ch, const u16string& text) { send_header_text(ch, 0xB0, 0, text, true); } @@ -694,6 +703,22 @@ void send_text_message(shared_ptr s, const u16string& text) { } } +__attribute__((format(printf, 2, 3))) void send_ep3_text_message_printf( + std::shared_ptr s, const char* format, ...) { + va_list va; + va_start(va, format); + std::string buf = string_vprintf(format, va); + va_end(va); + std::u16string decoded = decode_sjis(buf); + for (auto& it : s->id_to_lobby) { + for (auto& c : it.second->clients) { + if (c && (c->flags & Client::Flag::IS_EPISODE_3)) { + send_text_message(c, decoded); + } + } + } +} + u16string prepare_chat_message( GameVersion version, const u16string& from_name, @@ -1565,8 +1590,8 @@ void send_get_player_info(shared_ptr c) { //////////////////////////////////////////////////////////////////////////////// // Trade window -void send_execute_item_trade(std::shared_ptr c, - const std::vector& items) { +void send_execute_item_trade(shared_ptr c, + const vector& items) { SC_TradeItems_D0_D3 cmd; if (items.size() > sizeof(cmd.items) / sizeof(cmd.items[0])) { throw logic_error("too many items in execute trade command"); @@ -1579,8 +1604,8 @@ void send_execute_item_trade(std::shared_ptr c, send_command_t(c, 0xD3, 0x00, cmd); } -void send_execute_card_trade(std::shared_ptr c, - const std::vector>& card_to_count) { +void send_execute_card_trade(shared_ptr c, + const vector>& card_to_count) { if (!(c->flags & Client::Flag::IS_EPISODE_3)) { throw logic_error("cannot send trade cards command to non-Ep3 client"); } @@ -1841,10 +1866,10 @@ void send_ep3_card_list_update(shared_ptr s, shared_ptr c) } void send_ep3_media_update( - std::shared_ptr c, + shared_ptr c, uint32_t type, uint32_t which, - const std::string& compressed_data) { + const string& compressed_data) { StringWriter w; w.put({type, which, compressed_data.size(), 0}); w.write(compressed_data); @@ -1898,6 +1923,228 @@ void send_ep3_set_context_token(shared_ptr c, uint32_t context_token) { send_command_t(c, 0xC9, 0x00, cmd); } +void send_ep3_confirm_tournament_entry( + shared_ptr s, + shared_ptr c, + shared_ptr tourn) { + S_ConfirmTournamentEntry_GC_Ep3_CC cmd; + if (tourn) { + cmd.tournament_name = tourn->get_name(); + cmd.server_name = encode_sjis(s->name); + // TODO: Fill this in appropriately when we support scheduled start times + cmd.start_time = "Unknown"; + auto& teams = tourn->all_teams(); + for (size_t z = 0; z < min(teams.size(), 0x20); z++) { + cmd.entries[z].present = 1; + cmd.entries[z].team_name = teams[z]->name; + } + } + send_command_t(c, 0xCC, tourn ? 0x01 : 0x00, cmd); +} + +void send_ep3_tournament_list(shared_ptr s, shared_ptr c) { + S_TournamentList_GC_Ep3_E0 cmd; + size_t z = 0; + for (const auto& tourn : s->ep3_tournament_index->all_tournaments()) { + if (z >= 0x20) { + throw logic_error("more than 32 tournaments exist"); + } + auto& entry = cmd.entries[z]; + entry.menu_id = MenuID::TOURNAMENTS; + entry.item_id = tourn->get_number(); + // TODO: What does it mean for a tournament to be locked? Should we support + // that? + // TODO: Write appropriate round text (1st, 2nd, 3rd) here. This is + // nontrivial because unlike Sega's implementation, newserv does not require + // a round to completely finish before starting matches in the next round, + // as long as the winners of the preceding matches have been determined. + entry.state = + (tourn->get_state() == Episode3::Tournament::State::REGISTRATION) + ? 0x00 : 0x05; + // TODO: Fill in cmd.start_time here when we implement scheduled starts. + entry.name = tourn->get_name(); + const auto& teams = tourn->all_teams(); + for (auto team : teams) { + if (!team->name.empty()) { + entry.num_teams++; + } + } + entry.max_teams = teams.size(); + entry.unknown_a3.clear(0xFFFF); + z++; + } + send_command_t(c, 0xE0, z, cmd); +} + +void send_ep3_tournament_entry_list( + shared_ptr c, + shared_ptr tourn) { + S_TournamentEntryList_GC_Ep3_E2 cmd; + cmd.players_per_team = tourn->get_is_2v2() ? 2 : 1; + size_t z = 0; + for (const auto& team : tourn->all_teams()) { + if (z >= 0x20) { + throw logic_error("more than 32 teams in tournament"); + } + auto& entry = cmd.entries[z]; + entry.menu_id = MenuID::TOURNAMENT_ENTRIES; + entry.item_id = (tourn->get_number() << 16) | z; + entry.unknown_a2 = team->num_rounds_cleared; + entry.locked = team->password.empty() ? 0 : 1; + if (tourn->get_state() != Episode3::Tournament::State::REGISTRATION) { + entry.state = 2; + } else if (team->name.empty()) { + entry.state = 0; + } else if (team->player_serial_numbers.size() < team->max_players) { + entry.state = 1; + } else { + entry.state = 2; + } + entry.name = team->name; + z++; + } + send_command_t(c, 0xE2, z, cmd); +} + +void send_ep3_tournament_info( + std::shared_ptr c, + std::shared_ptr t) { + S_TournamentInfo_GC_Ep3_E3 cmd; + cmd.name = t->get_name(); + cmd.map_name = t->get_map()->map.name; + cmd.rules = t->get_rules(); + const auto& teams = t->all_teams(); + for (size_t z = 0; z < min(teams.size(), 0x20); z++) { + cmd.entries[z].win_count = teams[z]->num_rounds_cleared; + cmd.entries[z].is_active = teams[z]->is_active ? 1 : 0; + cmd.entries[z].team_name = teams[z]->name; + } + cmd.max_entries = teams.size(); + send_command_t(c, 0xE3, 0x02, cmd); +} + +void send_ep3_set_tournament_player_decks( + std::shared_ptr l, + std::shared_ptr c, + std::shared_ptr match) { + auto tourn = match->tournament.lock(); + if (!tourn) { + throw runtime_error("tournament is deleted"); + } + + G_SetTournamentPlayerDecks_GC_Ep3_6xB4x3D cmd; + cmd.rules = tourn->get_rules(); + cmd.map_number = tourn->get_map()->map.map_number.load(); + cmd.player_slot = 0xFF; + + for (size_t z = 0; z < 4; z++) { + auto& entry = cmd.entries[z]; + entry.player_name.clear(0); + entry.deck_name.clear(0); + entry.unknown_a1.clear(0); + entry.card_ids.clear(0); + entry.client_id = z; + } + + unordered_map> serial_number_to_client; + for (auto client : l->clients) { + if (client) { + serial_number_to_client.emplace(client->license->serial_number, client); + } + } + + size_t z = 0; + auto add_entries_for_team = [&](shared_ptr team) -> void { + for (uint32_t player_serial_number : team->player_serial_numbers) { + auto& entry = cmd.entries[z]; + entry.type = 1; // Human + entry.player_name = serial_number_to_client.at(player_serial_number)->game_data.player()->disp.name; + entry.unknown_a2 = 6; + if (player_serial_number == c->license->serial_number) { + cmd.player_slot = z; + } + z++; + } + for (auto com_deck : team->com_decks) { + auto& entry = cmd.entries[z]; + entry.type = 2; // COM + entry.player_name = com_deck->player_name; + entry.deck_name = com_deck->deck_name; + entry.card_ids = com_deck->card_ids; + entry.unknown_a2 = 6; + z++; + } + }; + add_entries_for_team(match->preceding_a->winner_team); + if (z < 1) { + throw logic_error("no entries from preceding team A"); + } + if (z > 2) { + throw logic_error("too many entries from preceding team A"); + } + z = 2; + add_entries_for_team(match->preceding_b->winner_team); + if (z < 3) { + throw logic_error("no entries from preceding team B"); + } + if (z > 4) { + throw logic_error("too many entries from preceding team B"); + } + + if (!(tourn->get_data_index()->behavior_flags & Episode3::BehaviorFlag::DISABLE_MASKING)) { + uint8_t mask_key = (random_object() % 0xFF) + 1; + set_mask_for_ep3_game_command(&cmd, sizeof(cmd), mask_key); + } + + send_command_t(c, 0xC9, 0x00, cmd); + + // TODO: Handle disconnection during the match (the other team should win) +} + +void send_ep3_tournament_match_result_result( + shared_ptr l, shared_ptr match) { + auto tourn = match->tournament.lock(); + if (!tourn) { + return; + } + + unordered_map> serial_number_to_client; + for (auto client : l->clients) { + if (client) { + serial_number_to_client.emplace(client->license->serial_number, client); + } + } + + auto write_player_names = [&](G_TournamentMatchResult_GC_Ep3_6xB4x51::NamesEntry& entry, shared_ptr team) -> void { + size_t z = 0; + for (uint32_t player_serial_number : team->player_serial_numbers) { + entry.player_names[z] = serial_number_to_client.at(player_serial_number)->game_data.player()->disp.name; + z++; + } + for (auto com_deck : team->com_decks) { + entry.player_names[z] = com_deck->player_name; + z++; + } + }; + + G_TournamentMatchResult_GC_Ep3_6xB4x51 cmd; + cmd.match_description = string_printf("(%s) Round %zu", tourn->get_name().c_str(), match->round_num); + cmd.names_entries[0].team_name = match->preceding_a->winner_team->name; + write_player_names(cmd.names_entries[0], match->preceding_a->winner_team); + cmd.names_entries[1].team_name = match->preceding_b->winner_team->name; + write_player_names(cmd.names_entries[1], match->preceding_b->winner_team); + cmd.result_entries[0].num_players = match->preceding_a->winner_team->max_players; + cmd.result_entries[0].is_winner_team = (match->preceding_a->winner_team == match->winner_team); + cmd.result_entries[1].num_players = match->preceding_a->winner_team->max_players; + cmd.result_entries[1].is_winner_team = (match->preceding_b->winner_team == match->winner_team); + // TODO: This amount should vary depending on the match level / round number, + // but newserv doesn't currently implement meseta at all - we just always give + // the player 1000000 and never charge for anything. + cmd.meseta_amount = 100; + cmd.meseta_reward_text = "You got %s meseta!"; + send_command_t(l, 0xC9, 0x00, cmd); +} + void set_mask_for_ep3_game_command(void* vdata, size_t size, uint8_t mask_key) { if (size < 8) { throw logic_error("Episode 3 game command is too short for masking"); @@ -1992,6 +2239,97 @@ void send_quest_file(shared_ptr c, const string& quest_name, } } +void send_quest_barrier_if_all_clients_ready(shared_ptr l) { + if (!l || !l->is_game()) { + return; + } + + // Check if any client is still loading + size_t x; + for (x = 0; x < l->max_clients; x++) { + if (!l->clients[x]) { + continue; + } + if (l->clients[x]->flags & Client::Flag::LOADING_QUEST) { + break; + } + } + + // If they're all done, start the quest + if (x == l->max_clients) { + send_command(l, 0xAC, 0x00); + } + + // Check if any client is still loading + for (x = 0; x < l->max_clients; x++) { + l->clients[x]->disconnect_hooks.erase(QUEST_BARRIER_DISCONNECT_HOOK_NAME); + } +} + +void send_card_auction_if_all_clients_ready( + shared_ptr s, shared_ptr l) { + // Check if any client is still not ready + size_t x; + for (x = 0; x < l->max_clients; x++) { + if (!l->clients[x]) { + continue; + } + if (!(l->clients[x]->flags & Client::Flag::AWAITING_CARD_AUCTION)) { + break; + } + } + if (x != l->max_clients) { + return; + } + + for (x = 0; x < l->max_clients; x++) { + if (l->clients[x]) { + l->clients[x]->flags &= ~Client::Flag::AWAITING_CARD_AUCTION; + } + } + + if ((s->ep3_card_auction_points == 0) || + (s->ep3_card_auction_min_size == 0) || + (s->ep3_card_auction_max_size == 0)) { + throw runtime_error("card auctions are not configured on this server"); + } + + uint16_t num_cards; + if (s->ep3_card_auction_min_size == s->ep3_card_auction_max_size) { + num_cards = s->ep3_card_auction_min_size; + } else { + num_cards = s->ep3_card_auction_min_size + + (random_object() % (s->ep3_card_auction_max_size - s->ep3_card_auction_min_size + 1)); + } + num_cards = min(num_cards, 0x14); + + uint64_t distribution_size = 0; + for (const auto& it : s->ep3_card_auction_pool) { + distribution_size += it.second.first; + } + + S_StartCardAuction_GC_Ep3_EF cmd; + cmd.points_available = s->ep3_card_auction_points; + for (size_t z = 0; z < num_cards; z++) { + uint64_t v = random_object() % distribution_size; + for (const auto& it : s->ep3_card_auction_pool) { + if (v >= it.second.first) { + v -= it.second.first; + } else { + cmd.entries[z].card_id = s->ep3_data_index->definition_for_card_name(it.first)->def.card_id.load(); + cmd.entries[z].min_price = it.second.second; + break; + } + } + } + send_command_t(l, 0xEF, num_cards, cmd); + + for (auto c : l->clients) { + if (c) { + c->disconnect_hooks.erase(CARD_AUCTION_DISCONNECT_HOOK_NAME); + } + } +} void send_server_time(shared_ptr c) { uint64_t t = now(); diff --git a/src/SendCommands.hh b/src/SendCommands.hh index 752801c0..910396df 100644 --- a/src/SendCommands.hh +++ b/src/SendCommands.hh @@ -163,6 +163,7 @@ void send_enter_directory_patch(std::shared_ptr c, const std::string& di void send_patch_file(std::shared_ptr c, std::shared_ptr f); void send_message_box(std::shared_ptr c, const std::u16string& text); +void send_ep3_timed_message_box(Channel& ch, uint32_t frames, const std::string& text); void send_lobby_name(std::shared_ptr c, const std::u16string& text); void send_quest_info(std::shared_ptr c, const std::u16string& text, bool is_download_quest); @@ -211,6 +212,9 @@ __attribute__((format(printf, 2, 3))) void send_text_message_printf( return send_text_message(t, decoded.c_str()); } +__attribute__((format(printf, 2, 3))) void send_ep3_text_message_printf( + std::shared_ptr s, const char* format, ...); + void send_info_board(std::shared_ptr c, std::shared_ptr l); void send_card_search_result( @@ -302,6 +306,26 @@ void send_ep3_rank_update(std::shared_ptr c); void send_ep3_card_battle_table_state(std::shared_ptr l, uint16_t table_number); void send_ep3_set_context_token(std::shared_ptr c, uint32_t context_token); +void send_ep3_confirm_tournament_entry( + std::shared_ptr s, + std::shared_ptr c, + std::shared_ptr t); +void send_ep3_tournament_list( + std::shared_ptr s, std::shared_ptr c); +void send_ep3_tournament_entry_list( + std::shared_ptr c, + std::shared_ptr t); +void send_ep3_tournament_info( + std::shared_ptr c, + std::shared_ptr t); +void send_ep3_set_tournament_player_decks( + std::shared_ptr l, + std::shared_ptr c, + std::shared_ptr match); +void send_ep3_tournament_match_result_result( + std::shared_ptr l, + std::shared_ptr match); + // Pass mask_key = 0 to unmask the command void set_mask_for_ep3_game_command(void* vdata, size_t size, uint8_t mask_key); @@ -315,6 +339,10 @@ enum class QuestFileType { void send_quest_file(std::shared_ptr c, const std::string& quest_name, const std::string& basename, const std::string& contents, QuestFileType type); +void send_quest_barrier_if_all_clients_ready(std::shared_ptr l); + +void send_card_auction_if_all_clients_ready( + std::shared_ptr s, std::shared_ptr l); void send_server_time(std::shared_ptr c); diff --git a/src/Server.cc b/src/Server.cc index d78030bc..9bd6f3f0 100644 --- a/src/Server.cc +++ b/src/Server.cc @@ -50,7 +50,8 @@ void Server::disconnect_client(shared_ptr c) { // We can't just let c be destroyed here, since disconnect_client can be // called from within the client's channel's receive handler. So, we instead // move it to another set, which we'll clear in an immediately-enqueued - // callback after the current event. + // callback after the current event. This will also call the client's + // disconnect hooks (if any). this->clients_to_destroy.insert(move(c)); } @@ -64,7 +65,22 @@ void Server::dispatch_destroy_clients(evutil_socket_t, short, void* ctx) { } void Server::destroy_clients() { - this->clients_to_destroy.clear(); + for (auto c_it = this->clients_to_destroy.begin(); + c_it != this->clients_to_destroy.end(); + c_it = this->clients_to_destroy.erase(c_it)) { + auto c = *c_it; + // Note: It's important to move the disconnect hooks out of the client here + // because the hooks could modify c->disconnect_hooks while it's being + // iterated here, which would invalidate these iterators. + unordered_map> hooks = move(c->disconnect_hooks); + for (auto h_it : hooks) { + try { + h_it.second(); + } catch (const exception& e) { + c->log.warning("Disconnect hook %s failed: %s", h_it.first.c_str(), e.what()); + } + } + } } void Server::dispatch_on_listen_accept( diff --git a/src/ServerShell.cc b/src/ServerShell.cc index ee762dfa..48e24ff5 100644 --- a/src/ServerShell.cc +++ b/src/ServerShell.cc @@ -41,6 +41,32 @@ static void set_boolean(bool* target, const string& args) { } } +static string get_quoted_string(string& s) { + string ret; + char end_char = (s.at(0) == '\"') ? '\"' : ' '; + size_t z = (s.at(0) == '\"') ? 1 : 0; + for (; (z < s.size()) && (s[z] != end_char); z++) { + if (s[z] == '\\') { + if (z + 1 < s.size()) { + ret.push_back(s[z + 1]); + } else { + throw runtime_error("incomplete escape sequence"); + } + } else { + ret.push_back(s[z]); + } + } + if (end_char != ' ') { + if (z >= s.size()) { + throw runtime_error("unterminated quoted string"); + } + s = s.substr(skip_whitespace(s, z + 1)); + } else { + s = s.substr(skip_whitespace(s, z)); + } + return ret; +} + void ServerShell::execute_command(const string& command) { // find the entry in the command table and run the command size_t command_end = skip_non_whitespace(command, 0); @@ -89,6 +115,22 @@ Server commands:\n\ Song IDs are 0 through 51; the default song is -1.\n\ announce \n\ Send an announcement message to all players.\n\ + create-tournament \"Tournament Name\" \"Map Name\" [rules...]\n\ + Create an Episode 3 tournament. Rules options:\n\ + dice=MIN-MAX: Set minimum and maximum dice rolls\n\ + overall-time-limit=N: Set battle time limit (in multiples of 5 minutes)\n\ + phase-time-limit=N: Set phase time limit (in seconds)\n\ + allowed-cards=ALL/N/NR/NRS: Set rarities of allowed cards\n\ + deck-shuffle=ON/OFF: Enable/disable deck shuffle\n\ + deck-loop=ON/OFF: Enable/disable deck loop\n\ + hp=N: Set Story Character initial HP\n\ + hp-type=TEAM/PLAYER/COMMON: Set team HP type\n\ + allow-assists=ON/OFF: Enable/disable assist cards\n\ + dialogue=ON/OFF: Enable/disable dialogue\n\ + dice-exchange=ATK/DEF/NONE: Set dice exchange mode\n\ + dice-boost=ON/OFF: Enable/disable dice boost\n\ + start-tournament \"Tournament Name\"\n\ + End registration for a tournament and allow matches to begin.\n\ \n\ Proxy commands (these will only work when exactly one client is connected):\n\ sc \n\ @@ -276,6 +318,103 @@ Proxy commands (these will only work when exactly one client is connected):\n\ u16string message16 = decode_sjis(command_args); send_text_message(this->state, message16.c_str()); + } else if (command_name == "create-tournament") { + string name = get_quoted_string(command_args); + string map_name = get_quoted_string(command_args); + auto map = this->state->ep3_data_index->definition_for_map_name(map_name); + uint32_t num_teams = stoul(get_quoted_string(command_args), nullptr, 0); + Episode3::Rules rules; + rules.set_defaults(); + bool is_2v2 = false; + if (!command_args.empty()) { + auto tokens = split(command_args, ' '); + for (auto& token : tokens) { + token = tolower(token); + if (token == "2v2") { + is_2v2 = true; + } else if (starts_with(token, "dice=")) { + auto subtokens = split(token.substr(5), '-'); + if (subtokens.size() != 2) { + throw runtime_error("dice option must be of the form dice=X-Y"); + } + rules.min_dice = stoul(subtokens[0]); + rules.max_dice = stoul(subtokens[0]); + } else if (starts_with(token, "overall-time-limit=")) { + uint32_t limit = stoul(token.substr(19)); + if (limit > 600) { + throw runtime_error("overall-time-limit must be 600 or fewer minutes"); + } + if (limit % 5) { + throw runtime_error("overall-time-limit must be a multiple of 5 minutes"); + } + rules.overall_time_limit = limit; + } else if (starts_with(token, "phase-time-limit=")) { + rules.phase_time_limit = stoul(token.substr(17)); + } else if (starts_with(token, "hp=")) { + rules.char_hp = stoul(token.substr(3)); + } else if (token == "allowed-cards=all") { + rules.allowed_cards = Episode3::AllowedCards::ALL; + } else if (token == "allowed-cards=n") { + rules.allowed_cards = Episode3::AllowedCards::N_ONLY; + } else if (token == "allowed-cards=nr") { + rules.allowed_cards = Episode3::AllowedCards::N_R_ONLY; + } else if (token == "allowed-cards=nrs") { + rules.allowed_cards = Episode3::AllowedCards::N_R_S_ONLY; + } else if (token == "deck-shuffle=on") { + rules.disable_deck_shuffle = 0; + } else if (token == "deck-shuffle=off") { + rules.disable_deck_shuffle = 1; + } else if (token == "deck-loop=on") { + rules.disable_deck_loop = 0; + } else if (token == "deck-loop=off") { + rules.disable_deck_loop = 1; + } else if (token == "allow-assists=on") { + rules.no_assist_cards = 0; + } else if (token == "allow-assists=off") { + rules.no_assist_cards = 1; + } else if (token == "dialogue=on") { + rules.disable_dialogue = 0; + } else if (token == "dialogue=off") { + rules.disable_dialogue = 1; + } else if (token == "dice-boost=on") { + rules.disable_dice_boost = 0; + } else if (token == "dice-boost=off") { + rules.disable_dice_boost = 1; + } else if (token == "hp-type=player") { + rules.hp_type = Episode3::HPType::DEFEAT_PLAYER; + } else if (token == "hp-type=team") { + rules.hp_type = Episode3::HPType::DEFEAT_TEAM; + } else if (token == "hp-type=common") { + rules.hp_type = Episode3::HPType::COMMON_HP; + } else if (token == "dice-exchange=atk") { + rules.dice_exchange_mode = Episode3::DiceExchangeMode::HIGH_ATK; + } else if (token == "dice-exchange=def") { + rules.dice_exchange_mode = Episode3::DiceExchangeMode::HIGH_DEF; + } else if (token == "dice-exchange=none") { + rules.dice_exchange_mode = Episode3::DiceExchangeMode::NONE; + } else { + throw runtime_error("invalid rules option: " + token); + } + } + } + if (rules.check_and_reset_invalid_fields()) { + fprintf(stderr, "warning: some rules were invalid and reset to defaults\n"); + } + auto tourn = this->state->ep3_tournament_index->create_tournament( + this->state->ep3_data_index, name, map, rules, num_teams, is_2v2); + fprintf(stderr, "created tournament %02hhX\n", tourn->get_number()); + + } else if (command_name == "start-tournament") { + string name = get_quoted_string(command_args); + auto tourn = this->state->ep3_tournament_index->get_tournament(name); + if (tourn) { + tourn->start(); + send_ep3_text_message_printf(this->state, "$C7The tournament\n$C6%s$C7\nhas begun", tourn->get_name().c_str()); + fprintf(stderr, "tournament started\n"); + } else { + fprintf(stderr, "no such tournament exists\n"); + } + // PROXY COMMANDS diff --git a/src/ServerState.cc b/src/ServerState.cc index 9f001696..9ffc86dd 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -26,6 +26,7 @@ ServerState::ServerState() catch_handler_exceptions(true), ep3_behavior_flags(0), run_shell_behavior(RunShellBehavior::DEFAULT), + ep3_tournament_index(new Episode3::TournamentIndex()), ep3_card_auction_points(0), ep3_card_auction_min_size(0), ep3_card_auction_max_size(0), @@ -120,7 +121,8 @@ void ServerState::remove_client_from_lobby(shared_ptr c) { } } -bool ServerState::change_client_lobby(shared_ptr c, shared_ptr new_lobby) { +bool ServerState::change_client_lobby( + shared_ptr c, shared_ptr new_lobby, bool send_join_notification) { uint8_t old_lobby_client_id = c->lobby_client_id; shared_ptr current_lobby = this->find_lobby(c->lobby_id); @@ -141,7 +143,9 @@ bool ServerState::change_client_lobby(shared_ptr c, shared_ptr ne send_player_leave_notification(current_lobby, old_lobby_client_id); } } - this->send_lobby_join_notifications(new_lobby, c); + if (send_join_notification) { + this->send_lobby_join_notifications(new_lobby, c); + } return true; } diff --git a/src/ServerState.hh b/src/ServerState.hh index 4e257032..3b1e7ff1 100644 --- a/src/ServerState.hh +++ b/src/ServerState.hh @@ -9,6 +9,8 @@ #include #include +#include "Episode3/DataIndex.hh" +#include "Episode3/Tournament.hh" #include "Client.hh" #include "FunctionCompiler.hh" #include "GSLArchive.hh" @@ -66,6 +68,8 @@ struct ServerState { std::shared_ptr bb_data_gsl; std::shared_ptr rare_item_set; + std::shared_ptr ep3_tournament_index; + uint16_t ep3_card_auction_points; uint16_t ep3_card_auction_min_size; uint16_t ep3_card_auction_max_size; @@ -115,7 +119,7 @@ struct ServerState { void add_client_to_available_lobby(std::shared_ptr c); void remove_client_from_lobby(std::shared_ptr c); bool change_client_lobby(std::shared_ptr c, - std::shared_ptr new_lobby); + std::shared_ptr new_lobby, bool send_join_notification = true); void send_lobby_join_notifications(std::shared_ptr l, std::shared_ptr joining_client); diff --git a/src/StaticGameData.cc b/src/StaticGameData.cc index 8b4ef6b8..b14fc213 100644 --- a/src/StaticGameData.cc +++ b/src/StaticGameData.cc @@ -327,6 +327,25 @@ char abbreviation_for_difficulty(uint8_t difficulty) { +char char_for_language_code(uint8_t language) { + switch (language) { + case 0: + return 'J'; + case 1: + return 'E'; + case 2: + return 'G'; + case 3: + return 'F'; + case 4: + return 'S'; + default: + return '?'; + } +} + + + size_t stack_size_for_item(uint8_t data0, uint8_t data1) { if (data0 == 4) { return 999999; diff --git a/src/StaticGameData.hh b/src/StaticGameData.hh index 19b853b2..5d05543c 100644 --- a/src/StaticGameData.hh +++ b/src/StaticGameData.hh @@ -47,4 +47,6 @@ const char* abbreviation_for_char_class(uint8_t cls); const char* name_for_difficulty(uint8_t difficulty); char abbreviation_for_difficulty(uint8_t difficulty); +char char_for_language_code(uint8_t language); + std::string name_for_item(const ItemData& item, bool include_color_codes); diff --git a/system/ep3/com-decks.json b/system/ep3/com-decks.json new file mode 100644 index 00000000..adbdf3f5 --- /dev/null +++ b/system/ep3/com-decks.json @@ -0,0 +1,14 @@ +[ + // Episode 3 tournament COM decks. These are randomly chosen for each COM + // player in a tournament. + // [PlayerName, DeckName, [CardID, ...]] + ["COM:D02", "Tremble", [0x0007, 0x006F, 0x006F, 0x006F, 0x0070, 0x0070, 0x01DC, 0x01DC, 0x01DC, 0x01DD, 0x01F1, 0x01F1, 0x01F1, 0x020B, 0x020B, 0x020E, 0x020E, 0x00ED, 0x00ED, 0x00ED, 0x00C6, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA, 0x0220, 0x0220, 0x0107]], + ["COM:D08", "Earthquake", [0x011E, 0x01E6, 0x01E6, 0x01E6, 0x01FB, 0x01FB, 0x01FB, 0x00C5, 0x00C5, 0x00C5, 0x00CA, 0x00CA, 0x00CA, 0x00CB, 0x00CB, 0x00CC, 0x00CC, 0x022C, 0x022C, 0x022C, 0x00E4, 0x00E4, 0x00E4, 0x00EF, 0x00EF, 0x00EF, 0x0215, 0x0215, 0x0215, 0x00ED, 0x00ED]], + ["COM:D10", "ONIGAMI", [0x011E, 0x01F1, 0x01F1, 0x01F1, 0x0078, 0x0078, 0x0078, 0x0079, 0x0079, 0x0079, 0x005B, 0x01E7, 0x01E7, 0x01E7, 0x0215, 0x0215, 0x0215, 0x00CF, 0x00CF, 0x00CF, 0x00C6, 0x00C6, 0x00C6, 0x00C5, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA]], + ["COM:D15", "GAGIGAGI!", [0x0005, 0x0155, 0x0155, 0x0155, 0x000A, 0x000A, 0x015C, 0x015C, 0x015C, 0x01A3, 0x01A3, 0x01A3, 0x0016, 0x0016, 0x0016, 0x008C, 0x008C, 0x00C6, 0x00C6, 0x00C6, 0x00C5, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA, 0x012E, 0x012E, 0x012E]], + ["COM:H01", "Rumble!", [0x0002, 0x0279, 0x0279, 0x0184, 0x0184, 0x0184, 0x000C, 0x000C, 0x000C, 0x0284, 0x0284, 0x0016, 0x0016, 0x0016, 0x0214, 0x0214, 0x00ED, 0x00ED, 0x00ED, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA, 0x0232, 0x0232, 0x0107, 0x0107, 0x0107]], + ["COM:H06", "Helpless!", [0x0112, 0x0169, 0x0169, 0x0169, 0x0256, 0x0256, 0x0041, 0x0041, 0x0041, 0x018F, 0x018F, 0x018F, 0x00D8, 0x00D9, 0x00D9, 0x00DA, 0x00DF, 0x00DF, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA, 0x0234, 0x0234, 0x00F6, 0x012B, 0x012B, 0x012B]], + ["COM:H07", "Storm", [0x0004, 0x00C5, 0x00C5, 0x00C5, 0x00C7, 0x00C7, 0x00CA, 0x00CA, 0x00CF, 0x00CF, 0x00D4, 0x00D4, 0x0215, 0x0215, 0x0215, 0x00EF, 0x00EF, 0x00EF, 0x00F0, 0x00ED, 0x00D9, 0x00D9, 0x00DA, 0x00DA, 0x01A4, 0x01A4, 0x01A4, 0x0017, 0x0017, 0x0017, 0x01A8]], + ["COM:H09", "Blow", [0x0004, 0x016B, 0x016B, 0x016B, 0x0171, 0x0171, 0x01A3, 0x01A3, 0x0017, 0x0017, 0x0017, 0x01A9, 0x01A9, 0x01A9, 0x0016, 0x0016, 0x0215, 0x0215, 0x0215, 0x00C6, 0x00C6, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA, 0x00CF, 0x00CF, 0x00CF]], + ["COM:H16", "Struggle", [0x0110, 0x000A, 0x000A, 0x000A, 0x015C, 0x015C, 0x015C, 0x01A3, 0x01A3, 0x01A3, 0x0017, 0x0017, 0x0017, 0x0016, 0x0016, 0x0016, 0x008B, 0x008B, 0x0093, 0x0093, 0x00C5, 0x00C5, 0x00C5, 0x00CB, 0x00CB, 0x00CB, 0x00CA, 0x00CA, 0x00CA, 0x0104, 0x0104]], +] \ No newline at end of file