diff --git a/README.md b/README.md index a558f385..ae05b340 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ Current known issues / missing features / things to do: - Encapsulate BB server-side random state and make replays deterministic. - The internal menu abstraction is ugly and hard to work with. Rewrite it. - Add default values for all commands (like we use for Episode 3 battle commands). -- Implement a way to persist tournament state across server restarts. ## Compatibility @@ -85,11 +84,9 @@ The following Episode 3 features are implemented, but only partially tested: 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.) -Note that tournament state is not persisted when the server restarts, so all tournaments will be in that case. This will be fixed in the future. - 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. +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. Tournament state is stored in system/ep3/tournament-state.json; this file is automatically written when any tournament changes state for any reason (e.g. a tournament is created/started/deleted or a match is resolved). ## Usage diff --git a/src/Episode3/DataIndex.cc b/src/Episode3/DataIndex.cc index 124e34fc..909ac82b 100644 --- a/src/Episode3/DataIndex.cc +++ b/src/Episode3/DataIndex.cc @@ -792,6 +792,41 @@ Rules::Rules() { this->clear(); } +Rules::Rules(shared_ptr json) { + auto dict = json->as_dict(); + this->overall_time_limit = dict.at("overall_time_limit")->as_int(); + this->phase_time_limit = dict.at("phase_time_limit")->as_int(); + this->allowed_cards = static_cast(dict.at("allowed_cards")->as_int()); + this->min_dice = dict.at("min_dice")->as_int(); + this->max_dice = dict.at("max_dice")->as_int(); + this->disable_deck_shuffle = dict.at("disable_deck_shuffle")->as_int(); + this->disable_deck_loop = dict.at("disable_deck_loop")->as_int(); + this->char_hp = dict.at("char_hp")->as_int(); + this->hp_type = static_cast(dict.at("hp_type")->as_int()); + this->no_assist_cards = dict.at("no_assist_cards")->as_int(); + this->disable_dialogue = dict.at("disable_dialogue")->as_int(); + this->dice_exchange_mode = static_cast(dict.at("dice_exchange_mode")->as_int()); + this->disable_dice_boost = dict.at("disable_dice_boost")->as_int(); +} + +shared_ptr Rules::json() const { + unordered_map> dict; + dict.emplace("overall_time_limit", make_json_int(this->overall_time_limit)); + dict.emplace("phase_time_limit", make_json_int(this->phase_time_limit)); + dict.emplace("allowed_cards", make_json_int(static_cast(this->allowed_cards))); + dict.emplace("min_dice", make_json_int(this->min_dice)); + dict.emplace("max_dice", make_json_int(this->max_dice)); + dict.emplace("disable_deck_shuffle", make_json_int(this->disable_deck_shuffle)); + dict.emplace("disable_deck_loop", make_json_int(this->disable_deck_loop)); + dict.emplace("char_hp", make_json_int(this->char_hp)); + dict.emplace("hp_type", make_json_int(static_cast(this->hp_type))); + dict.emplace("no_assist_cards", make_json_int(this->no_assist_cards)); + dict.emplace("disable_dialogue", make_json_int(this->disable_dialogue)); + dict.emplace("dice_exchange_mode", make_json_int(static_cast(this->dice_exchange_mode))); + dict.emplace("disable_dice_boost", make_json_int(this->disable_dice_boost)); + return shared_ptr(new JSONObject(move(dict))); +} + void Rules::set_defaults() { this->clear(); this->overall_time_limit = 24; // 2 hours @@ -1364,6 +1399,9 @@ DataIndex::DataIndex(const string& directory, uint32_t behavior_flags) for (size_t z = 0; z < 0x1F; z++) { def->card_ids[z] = card_ids_json.at(z)->as_int(); } + if (!this->com_decks_by_name.emplace(def->deck_name, def).second) { + throw runtime_error("duplicate COM deck name: " + def->deck_name); + } } } catch (const exception& e) { static_game_data_log.warning("Failed to load Episode 3 COM decks: %s", e.what()); @@ -1498,6 +1536,10 @@ shared_ptr DataIndex::com_deck(size_t which) const { return this->com_decks.at(which); } +shared_ptr DataIndex::com_deck(const string& which) const { + return this->com_decks_by_name.at(which); +} + shared_ptr DataIndex::random_com_deck() const { return this->com_decks[random_object() % this->com_decks.size()]; } diff --git a/src/Episode3/DataIndex.hh b/src/Episode3/DataIndex.hh index 9e52826c..3db0ae9d 100644 --- a/src/Episode3/DataIndex.hh +++ b/src/Episode3/DataIndex.hh @@ -8,6 +8,7 @@ #include #include #include +#include #include "../Text.hh" @@ -639,6 +640,8 @@ struct Rules { parray unused; Rules(); + explicit Rules(std::shared_ptr json); + std::shared_ptr json() const; bool operator==(const Rules& other) const; bool operator!=(const Rules& other) const; void clear(); @@ -838,6 +841,7 @@ public: size_t num_com_decks() const; std::shared_ptr com_deck(size_t which) const; + std::shared_ptr com_deck(const std::string& name) const; std::shared_ptr random_com_deck() const; const uint32_t behavior_flags; @@ -856,6 +860,7 @@ private: std::unordered_map> maps_by_name; std::vector> com_decks; + std::unordered_map> com_decks_by_name; }; diff --git a/src/Episode3/Tournament.cc b/src/Episode3/Tournament.cc index 1f21bffb..11e18d90 100644 --- a/src/Episode3/Tournament.cc +++ b/src/Episode3/Tournament.cc @@ -179,7 +179,7 @@ void Tournament::Match::on_winner_team_set() { } } -void Tournament::Match::set_winner_team(shared_ptr team) { +void Tournament::Match::set_winner_team_without_triggers(shared_ptr team) { if (!this->preceding_a || !this->preceding_b) { throw logic_error("set_winner_team called on zero-round match"); } @@ -196,7 +196,10 @@ void Tournament::Match::set_winner_team(shared_ptr team) { } else { this->preceding_a->winner_team->is_active = false; } +} +void Tournament::Match::set_winner_team(shared_ptr team) { + this->set_winner_team_without_triggers(team); this->on_winner_team_set(); } @@ -244,20 +247,65 @@ Tournament::Tournament( } } +Tournament::Tournament( + std::shared_ptr data_index, + uint8_t number, + std::shared_ptr json) + : log(string_printf("[Tournament/%02hhX] ", number)), + data_index(data_index), + source_json(json), + number(number), + current_state(State::REGISTRATION) { } + 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)); + vector team_index_to_rounds_cleared; + + bool is_registration_complete; + if (this->source_json) { + auto& dict = this->source_json->as_dict(); + this->name = dict.at("name")->as_string(); + this->map = this->data_index->definition_for_map_number(dict.at("map_number")->as_int()); + this->rules = Rules(dict.at("rules")); + this->is_2v2 = dict.at("is_2v2")->as_bool(); + is_registration_complete = dict.at("is_registration_complete")->as_bool(); + + for (const auto& team_json : dict.at("teams")->as_list()) { + auto& team_dict = team_json->as_dict(); + auto& team = this->teams.emplace_back(new Team( + this->shared_from_this(), + this->teams.size(), + team_dict.at("max_players")->as_int())); + team->name = team_dict.at("name")->as_string(); + team->password = team_dict.at("password")->as_string(); + team_index_to_rounds_cleared.emplace_back(team_dict.at("num_rounds_cleared")->as_int()); + for (const auto& serial_number_json : team_dict.at("player_serial_numbers")->as_list()) { + uint32_t serial_number = serial_number_json->as_int(); + team->player_serial_numbers.emplace(serial_number); + this->all_player_serial_numbers.emplace(serial_number); + } + for (const auto& com_deck_name_json : team_dict.at("com_deck_names")->as_list()) { + team->com_decks.emplace_back(this->data_index->com_deck( + com_deck_name_json->as_string())); + } + } + this->num_teams = this->teams.size(); + + this->source_json.reset(); + + } else { + // Create empty teams + 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); + } + is_registration_complete = false; } - // 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 match structure + while (this->zero_round_matches.size() < this->num_teams) { + this->zero_round_matches.emplace_back(make_shared( + this->shared_from_this(), this->teams[this->zero_round_matches.size()])); } // Create the bracket matches @@ -276,6 +324,109 @@ void Tournament::init() { current_round_matches = move(next_round_matches); } this->final_match = current_round_matches.at(0); + + // Compute the match state from the teams' states + if (is_registration_complete) { + this->current_state = State::IN_PROGRESS; + + // Start with all first-round matches in the match queue + unordered_set> match_queue; + for (auto match : this->zero_round_matches) { + match_queue.emplace(match->following.lock()); + } + if (match_queue.count(nullptr)) { + throw logic_error("null match in match queue"); + } + + // For each match in the queue, either resolve it from the previous state or + // mark it as unresolvable (hence it should be pending when we're done) + while (!match_queue.empty()) { + auto match_it = match_queue.begin(); + auto match = *match_it; + match_queue.erase(match_it); + + if (!match->preceding_a->winner_team || !match->preceding_b->winner_team) { + throw logic_error("preceding matches are not resolved"); + } + size_t& a_rounds_cleared = team_index_to_rounds_cleared[ + match->preceding_a->winner_team->index]; + size_t& b_rounds_cleared = team_index_to_rounds_cleared[ + match->preceding_b->winner_team->index]; + if (a_rounds_cleared && b_rounds_cleared) { + throw runtime_error("both teams won the same match"); + } + if (!a_rounds_cleared && !b_rounds_cleared) { + this->pending_matches.emplace(match); // Neither team has won yet + } else { + if (a_rounds_cleared) { + a_rounds_cleared--; + match->set_winner_team_without_triggers(match->preceding_a->winner_team); + } else { + b_rounds_cleared--; + match->set_winner_team_without_triggers(match->preceding_b->winner_team); + } + + // If both preceding matches of the following match are resolved, put + // the following match on the queue since it may be resolvable as well + auto following = match->following.lock(); + if (following && + following->preceding_a->winner_team && + following->preceding_b->winner_team) { + match_queue.emplace(following); + } + } + } + + if (!this->final_match->winner_team == this->pending_matches.empty()) { + throw logic_error("there must be pending matches if and only if the final match is not resolved"); + } + + // If all matches are resolved, then the tournament is complete + if (this->final_match->winner_team) { + this->current_state = State::COMPLETE; + } + + } else { + // 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); + } + + this->current_state = State::REGISTRATION; + } +} + +std::shared_ptr Tournament::json() const { + unordered_map> dict; + dict.emplace("name", make_json_str(this->name)); + dict.emplace("map_number", make_json_int(this->map->map.map_number)); + dict.emplace("rules", this->rules.json()); + dict.emplace("is_2v2", make_json_bool(this->is_2v2)); + dict.emplace("is_registration_complete", make_json_bool( + this->current_state != State::REGISTRATION)); + + vector> teams_list; + for (auto team : this->teams) { + unordered_map> team_dict; + team_dict.emplace("max_players", make_json_int(team->max_players)); + vector> player_serial_numbers_list; + for (uint32_t player_serial_number : team->player_serial_numbers) { + player_serial_numbers_list.emplace_back(make_json_int(player_serial_number)); + } + team_dict.emplace("player_serial_numbers", make_json_list(move(player_serial_numbers_list))); + vector> com_deck_names_list; + for (auto com_deck : team->com_decks) { + com_deck_names_list.emplace_back(make_json_str(com_deck->deck_name)); + } + team_dict.emplace("com_deck_names", make_json_list(move(com_deck_names_list))); + team_dict.emplace("name", make_json_str(team->name)); + team_dict.emplace("password", make_json_str(team->password)); + team_dict.emplace("num_rounds_cleared", make_json_int(team->num_rounds_cleared)); + teams_list.emplace_back(new JSONObject(move(team_dict))); + } + dict.emplace("teams", make_json_list(move(teams_list))); + return shared_ptr(new JSONObject(move(dict))); } std::shared_ptr Tournament::get_data_index() const { @@ -382,7 +533,7 @@ void Tournament::start() { 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()); + t->com_decks.emplace_back(this->data_index->random_com_deck()); } } @@ -438,6 +589,46 @@ void Tournament::print_bracket(FILE* stream) const { +TournamentIndex::TournamentIndex( + shared_ptr data_index, + const string& state_filename, + bool skip_load_state) + : data_index(data_index), state_filename(state_filename) { + if (this->state_filename.empty() || skip_load_state) { + return; + } + + auto json = JSONObject::parse(load_file(this->state_filename)); + + auto& list = json->as_list(); + if (list.size() != 0x20) { + throw runtime_error("tournament JSON list length is incorrect"); + } + for (size_t z = 0; z < 0x20; z++) { + if (!list.at(z)->is_null()) { + this->tournaments[z].reset(new Tournament(this->data_index, z, list[z])); + this->tournaments[z]->init(); + } + } +} + +void TournamentIndex::save() const { + if (this->state_filename.empty()) { + return; + } + + vector> list; + for (size_t z = 0; z < 0x20; z++) { + if (this->tournaments[z]) { + list.emplace_back(this->tournaments[z]->json()); + } else { + list.emplace_back(make_json_null()); + } + } + auto json = make_json_list(move(list)); + save_file(this->state_filename, json->format()); +} + vector> TournamentIndex::all_tournaments() const { vector> ret; for (size_t z = 0; z < 0x20; z++) { @@ -449,7 +640,6 @@ vector> TournamentIndex::all_tournaments() const { } shared_ptr TournamentIndex::create_tournament( - shared_ptr data_index, const string& name, shared_ptr map, const Rules& rules, @@ -466,7 +656,8 @@ shared_ptr TournamentIndex::create_tournament( throw runtime_error("all tournament slots are full"); } - auto t = make_shared(data_index, number, name, map, rules, num_teams, is_2v2); + auto t = make_shared( + this->data_index, number, name, map, rules, num_teams, is_2v2); t->init(); this->tournaments[number] = t; return t; diff --git a/src/Episode3/Tournament.hh b/src/Episode3/Tournament.hh index 2685109c..981c0102 100644 --- a/src/Episode3/Tournament.hh +++ b/src/Episode3/Tournament.hh @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "../Player.hh" @@ -19,9 +20,6 @@ namespace Episode3 { -// 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 { @@ -35,7 +33,7 @@ public: size_t index; size_t max_players; std::set player_serial_numbers; - std::set> com_decks; + std::vector> com_decks; std::string name; std::string password; size_t num_rounds_cleared; @@ -55,10 +53,6 @@ public: }; 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; @@ -78,6 +72,7 @@ public: bool resolve_if_no_players(); void on_winner_team_set(); void set_winner_team(std::shared_ptr team); + void set_winner_team_without_triggers(std::shared_ptr team); std::shared_ptr opponent_team_for_team(std::shared_ptr team) const; }; @@ -89,9 +84,15 @@ public: const Rules& rules, size_t num_teams, bool is_2v2); + Tournament( + std::shared_ptr data_index, + uint8_t number, + std::shared_ptr json); ~Tournament() = default; void init(); + std::shared_ptr json() const; + std::shared_ptr get_data_index() const; uint8_t get_number() const; const std::string& get_name() const; @@ -115,6 +116,7 @@ private: PrefixedLogger log; std::shared_ptr data_index; + std::shared_ptr source_json; uint8_t number; std::string name; std::shared_ptr map; @@ -141,13 +143,17 @@ private: class TournamentIndex { public: - TournamentIndex() = default; + explicit TournamentIndex( + std::shared_ptr data_index, + const std::string& state_filename, + bool skip_load_state = false); ~TournamentIndex() = default; + void save() const; + 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, @@ -161,6 +167,8 @@ public: uint32_t serial_number) const; private: + std::shared_ptr data_index; + std::string state_filename; std::shared_ptr tournaments[0x20]; }; diff --git a/src/Main.cc b/src/Main.cc index 61c504a3..73e9542e 100644 --- a/src/Main.cc +++ b/src/Main.cc @@ -914,6 +914,17 @@ int main(int argc, char** argv) { state->ep3_data_index.reset(new Episode3::DataIndex( "system/ep3", state->ep3_behavior_flags)); + const string& tournament_state_filename = "system/ep3/tournament-state.json"; + try { + state->ep3_tournament_index.reset(new Episode3::TournamentIndex( + state->ep3_data_index, tournament_state_filename)); + config_log.info("Loaded Episode 3 tournament state"); + } catch (const exception& e) { + config_log.warning("Cannot load Episode 3 tournament state: %s", e.what()); + state->ep3_tournament_index.reset(new Episode3::TournamentIndex( + state->ep3_data_index, tournament_state_filename, true)); + } + config_log.info("Collecting quest metadata"); state->quest_index.reset(new QuestIndex("system/quests")); diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 30b845fc..49be1841 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -1120,6 +1120,7 @@ static void on_ep3_server_data_request(shared_ptr s, shared_ptrget_state() == Episode3::Tournament::State::COMPLETE)) { s->ep3_tournament_index->delete_tournament(tourn->get_number()); } + s->ep3_tournament_index->save(); } } @@ -1132,6 +1133,7 @@ static void on_tournament_complete( 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()); + s->ep3_tournament_index->save(); } static void on_ep3_tournament_control(shared_ptr s, shared_ptr c, @@ -1891,6 +1893,8 @@ 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()); + s->ep3_tournament_index->save(); + } catch (const exception& e) { string message = string_printf("Cannot join team:\n%s", e.what()); send_lobby_message_box(c, decode_sjis(message)); diff --git a/src/ServerShell.cc b/src/ServerShell.cc index 4aeddea5..9c84a79e 100644 --- a/src/ServerShell.cc +++ b/src/ServerShell.cc @@ -129,10 +129,17 @@ Server commands:\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\ + delete-tournament \"Tournament Name\"\n\ + Delete a tournament. The quotes are required unless the tournament name\n\ + contains no spaces.\n\ + list-tournaments\n\ + List the names and numbers of all existing tournaments.\n\ start-tournament \"Tournament Name\"\n\ - End registration for a tournament and allow matches to begin.\n\ + End registration for a tournament and allow matches to begin. The quotes\n\ + are required unless the tournament name contains no spaces.\n\ tournament-state \"Tournament Name\"\n\ - Print the current state of a tournament.\n\ + Print the current state of a tournament. The quotes are required unless the\n\ + tournament name contains no spaces.\n\ \n\ Proxy commands (these will only work when exactly one client is connected):\n\ sc \n\ @@ -403,14 +410,32 @@ Proxy commands (these will only work when exactly one client is connected):\n\ 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); + name, map, rules, num_teams, is_2v2); + this->state->ep3_tournament_index->save(); fprintf(stderr, "created tournament %02hhX\n", tourn->get_number()); + } else if (command_name == "delete-tournament") { + string name = get_quoted_string(command_args); + auto tourn = this->state->ep3_tournament_index->get_tournament(name); + if (tourn) { + this->state->ep3_tournament_index->delete_tournament(tourn->get_number()); + this->state->ep3_tournament_index->save(); + fprintf(stderr, "tournament deleted\n"); + } else { + fprintf(stderr, "no such tournament exists\n"); + } + + } else if (command_name == "list-tournaments") { + for (const auto& tourn : this->state->ep3_tournament_index->all_tournaments()) { + fprintf(stderr, " %s\n", tourn->get_name().c_str()); + } + } 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(); + this->state->ep3_tournament_index->save(); 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 { diff --git a/src/ServerState.cc b/src/ServerState.cc index 9ffc86dd..2c327e3d 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -26,7 +26,6 @@ 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),