persist tournament state across server restarts

This commit is contained in:
Martin Michelsen
2022-12-10 00:13:49 -08:00
parent b0a32600be
commit fb4aa0df22
9 changed files with 315 additions and 33 deletions
+1 -4
View File
@@ -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
+42
View File
@@ -792,6 +792,41 @@ Rules::Rules() {
this->clear();
}
Rules::Rules(shared_ptr<const JSONObject> 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<AllowedCards>(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<HPType>(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<DiceExchangeMode>(dict.at("dice_exchange_mode")->as_int());
this->disable_dice_boost = dict.at("disable_dice_boost")->as_int();
}
shared_ptr<JSONObject> Rules::json() const {
unordered_map<string, shared_ptr<JSONObject>> 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<uint8_t>(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<uint8_t>(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<uint8_t>(this->dice_exchange_mode)));
dict.emplace("disable_dice_boost", make_json_int(this->disable_dice_boost));
return shared_ptr<JSONObject>(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<const COMDeckDefinition> DataIndex::com_deck(size_t which) const {
return this->com_decks.at(which);
}
shared_ptr<const COMDeckDefinition> DataIndex::com_deck(const string& which) const {
return this->com_decks_by_name.at(which);
}
shared_ptr<const COMDeckDefinition> DataIndex::random_com_deck() const {
return this->com_decks[random_object<size_t>() % this->com_decks.size()];
}
+5
View File
@@ -8,6 +8,7 @@
#include <memory>
#include <unordered_map>
#include <phosg/Encoding.hh>
#include <phosg/JSON.hh>
#include "../Text.hh"
@@ -639,6 +640,8 @@ struct Rules {
parray<uint8_t, 3> unused;
Rules();
explicit Rules(std::shared_ptr<const JSONObject> json);
std::shared_ptr<JSONObject> 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<const COMDeckDefinition> com_deck(size_t which) const;
std::shared_ptr<const COMDeckDefinition> com_deck(const std::string& name) const;
std::shared_ptr<const COMDeckDefinition> random_com_deck() const;
const uint32_t behavior_flags;
@@ -856,6 +860,7 @@ private:
std::unordered_map<std::string, std::shared_ptr<MapEntry>> maps_by_name;
std::vector<std::shared_ptr<COMDeckDefinition>> com_decks;
std::unordered_map<std::string, std::shared_ptr<COMDeckDefinition>> com_decks_by_name;
};
+206 -15
View File
@@ -179,7 +179,7 @@ void Tournament::Match::on_winner_team_set() {
}
}
void Tournament::Match::set_winner_team(shared_ptr<Team> team) {
void Tournament::Match::set_winner_team_without_triggers(shared_ptr<Team> 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> team) {
} else {
this->preceding_a->winner_team->is_active = false;
}
}
void Tournament::Match::set_winner_team(shared_ptr<Team> team) {
this->set_winner_team_without_triggers(team);
this->on_winner_team_set();
}
@@ -244,20 +247,65 @@ Tournament::Tournament(
}
}
Tournament::Tournament(
std::shared_ptr<const DataIndex> data_index,
uint8_t number,
std::shared_ptr<const JSONObject> 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<Team>(
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<Match>(
this->shared_from_this(), t));
vector<size_t> 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<Team>(
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<Match>(
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<shared_ptr<Match>> 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<JSONObject> Tournament::json() const {
unordered_map<string, shared_ptr<JSONObject>> 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<shared_ptr<JSONObject>> teams_list;
for (auto team : this->teams) {
unordered_map<string, shared_ptr<JSONObject>> team_dict;
team_dict.emplace("max_players", make_json_int(team->max_players));
vector<shared_ptr<JSONObject>> 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<shared_ptr<JSONObject>> 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<JSONObject>(new JSONObject(move(dict)));
}
std::shared_ptr<const DataIndex> 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<const DataIndex> 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<shared_ptr<JSONObject>> 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<shared_ptr<Tournament>> TournamentIndex::all_tournaments() const {
vector<shared_ptr<Tournament>> ret;
for (size_t z = 0; z < 0x20; z++) {
@@ -449,7 +640,6 @@ vector<shared_ptr<Tournament>> TournamentIndex::all_tournaments() const {
}
shared_ptr<Tournament> TournamentIndex::create_tournament(
shared_ptr<const DataIndex> data_index,
const string& name,
shared_ptr<const DataIndex::MapEntry> map,
const Rules& rules,
@@ -466,7 +656,8 @@ shared_ptr<Tournament> TournamentIndex::create_tournament(
throw runtime_error("all tournament slots are full");
}
auto t = make_shared<Tournament>(data_index, number, name, map, rules, num_teams, is_2v2);
auto t = make_shared<Tournament>(
this->data_index, number, name, map, rules, num_teams, is_2v2);
t->init();
this->tournaments[number] = t;
return t;
+18 -10
View File
@@ -7,6 +7,7 @@
#include <vector>
#include <unordered_set>
#include <string>
#include <phosg/JSON.hh>
#include <phosg/Strings.hh>
#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<Tournament> {
public:
enum class State {
@@ -35,7 +33,7 @@ public:
size_t index;
size_t max_players;
std::set<uint32_t> player_serial_numbers;
std::set<std::shared_ptr<const COMDeckDefinition>> com_decks;
std::vector<std::shared_ptr<const COMDeckDefinition>> 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<Match> {
enum class WinnerTeam {
A = 0,
B = 1,
};
std::weak_ptr<Tournament> tournament;
std::shared_ptr<Match> preceding_a;
std::shared_ptr<Match> 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> team);
void set_winner_team_without_triggers(std::shared_ptr<Team> team);
std::shared_ptr<Team> opponent_team_for_team(std::shared_ptr<Team> team) const;
};
@@ -89,9 +84,15 @@ public:
const Rules& rules,
size_t num_teams,
bool is_2v2);
Tournament(
std::shared_ptr<const DataIndex> data_index,
uint8_t number,
std::shared_ptr<const JSONObject> json);
~Tournament() = default;
void init();
std::shared_ptr<JSONObject> json() const;
std::shared_ptr<const DataIndex> 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<const DataIndex> data_index;
std::shared_ptr<const JSONObject> source_json;
uint8_t number;
std::string name;
std::shared_ptr<const DataIndex::MapEntry> map;
@@ -141,13 +143,17 @@ private:
class TournamentIndex {
public:
TournamentIndex() = default;
explicit TournamentIndex(
std::shared_ptr<const DataIndex> data_index,
const std::string& state_filename,
bool skip_load_state = false);
~TournamentIndex() = default;
void save() const;
std::vector<std::shared_ptr<Tournament>> all_tournaments() const;
std::shared_ptr<Tournament> create_tournament(
std::shared_ptr<const DataIndex> data_index,
const std::string& name,
std::shared_ptr<const DataIndex::MapEntry> map,
const Rules& rules,
@@ -161,6 +167,8 @@ public:
uint32_t serial_number) const;
private:
std::shared_ptr<const DataIndex> data_index;
std::string state_filename;
std::shared_ptr<Tournament> tournaments[0x20];
};
+11
View File
@@ -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"));
+4
View File
@@ -1120,6 +1120,7 @@ static void on_ep3_server_data_request(shared_ptr<ServerState> s, shared_ptr<Cli
if (tourn && (tourn->get_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<ServerState> s, shared_ptr<Client> 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));
+28 -3
View File
@@ -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 <data>\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 {
-1
View File
@@ -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),