diff --git a/README.md b/README.md index e0212e71..bec31be5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Current known issues / missing features / things to do: - Disconnecting during a tournament when there are no other humans in the match simply cancels the match (so it can be replayed) instead of forfeiting, which is almost certainly incorrect behavior. (Then again, no one likes losing tournaments to COMs...) - Tournament deck restrictions aren't enforced when populating COMs at tournament start time. This can cause weird behavior if, for example, a COM deck contains assist cards and the tournament rules forbid them. - There is a rare failure mode during battles that causes one of the clients to be disconnected. - - Battle Tables don't work properly (except for tournaments). + - Battle tables may work properly, but are not well-tested. ## Compatibility diff --git a/src/Client.cc b/src/Client.cc index cd13988b..6a77f195 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -55,6 +55,7 @@ Client::Client( event_free), card_battle_table_number(-1), card_battle_table_seat_number(0), + card_battle_table_seat_state(0), next_exp_value(0), override_section_id(-1), override_random_seed(-1), diff --git a/src/Client.hh b/src/Client.hh index 171e2f64..9a537441 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -114,7 +114,8 @@ struct Client { ClientGameData game_data; std::unique_ptr save_game_data_event; int16_t card_battle_table_number; - uint8_t card_battle_table_seat_number; + uint16_t card_battle_table_seat_number; + uint16_t card_battle_table_seat_state; std::weak_ptr ep3_tournament_team; // Miscellaneous (used by chat commands) diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index 8b64548d..63e9e591 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -2512,16 +2512,21 @@ struct C_PlayerPreviewRequest_BB_E3 { // When client sends an E4, server should respond with another E4 (but these // commands have different formats). -// Header flag = seated state (1 = present, 0 = leaving) +// header.flag = seated state (1 = present, 0 = leaving) struct C_CardBattleTableState_GC_Ep3_E4 { le_uint16_t table_number = 0; le_uint16_t seat_number = 0; } __packed__; -// Header flag = table number +// header.flag = table number struct S_CardBattleTableState_GC_Ep3_E4 { struct Entry { - le_uint16_t present = 0; // 1 = player present, 0 = no player + // State values: + // 0 = no player present + // 1 = player present, not confirmed + // 2 = player present, confirmed + // 3 = player presend, declined + le_uint16_t state = 0; le_uint16_t unknown_a1 = 0; le_uint32_t guild_card_number = 0; } __packed__; diff --git a/src/Lobby.hh b/src/Lobby.hh index ad947306..f82858bb 100644 --- a/src/Lobby.hh +++ b/src/Lobby.hh @@ -107,7 +107,7 @@ struct Lobby : public std::enable_shared_from_this { std::shared_ptr loading_quest; std::array, 12> clients; // Keys in this map are client_id - std::unordered_map> tournament_clients_to_add; + std::unordered_map> clients_to_add; explicit Lobby(uint32_t id); diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 061aecc4..674b120c 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -26,8 +26,10 @@ using namespace std; +const char* BATTLE_TABLE_DISCONNECT_HOOK_NAME = "battle_table_state"; const char* QUEST_BARRIER_DISCONNECT_HOOK_NAME = "quest_barrier"; const char* CARD_AUCTION_DISCONNECT_HOOK_NAME = "card_auction"; +const char* ADD_NEXT_CLIENT_DISCONNECT_HOOK_NAME = "add_next_game_client"; @@ -877,27 +879,21 @@ static void on_ep3_meseta_transaction(shared_ptr, send_command(c, command, 0x03, &out_cmd, sizeof(out_cmd)); } -static bool add_next_tournament_match_client( +static bool add_next_game_client( shared_ptr s, shared_ptr l) { - if (!l->tournament_match) { - return false; - } - auto tourn = l->tournament_match->tournament.lock(); - if (!tourn) { - return false; - } - - auto it = l->tournament_clients_to_add.begin(); - if (it == l->tournament_clients_to_add.end()) { + auto it = l->clients_to_add.begin(); + if (it == l->clients_to_add.end()) { return false; } size_t target_client_id = it->first; shared_ptr c = it->second.lock(); - l->tournament_clients_to_add.erase(it); + l->clients_to_add.erase(it); - // If the client has disconnected before they could join the match, disband - // the entire game - if (!c) { + auto tourn = l->tournament_match ? l->tournament_match->tournament.lock() : nullptr; + + // If the game is a tournament match and the client has disconnected before + // they could join the match, disband the entire game + if (!c && l->tournament_match) { send_command(l, 0xED, 0x00); return false; } @@ -906,22 +902,25 @@ static bool add_next_tournament_match_client( throw logic_error("client id is already in use"); } - 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); - if (!(s->ep3_data_index->behavior_flags & Episode3::BehaviorFlag::DISABLE_MASKING)) { - uint8_t mask_key = (random_object() % 0xFF) + 1; - set_mask_for_ep3_game_command(&state_cmd, sizeof(state_cmd), mask_key); + if (tourn) { + 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); + if (!(s->ep3_data_index->behavior_flags & Episode3::BehaviorFlag::DISABLE_MASKING)) { + uint8_t mask_key = (random_object() % 0xFF) + 1; + set_mask_for_ep3_game_command(&state_cmd, sizeof(state_cmd), mask_key); + } + send_command_t(c, 0xC9, 0x00, state_cmd); } // For the final match, use higher EX values. @@ -936,8 +935,9 @@ static bool add_next_tournament_match_client( static const std::pair final_lose_entries[10] = { {1, -5}, {-1, -10}, {-3, -15}, {-7, -20}, {-15, -20}, {-20, -25}, {-30, -30}, {-40, -30}, {-50, -34}, {0, -40}}; G_SetEXResultValues_GC_Ep3_6xB4x4B ex_cmd; - const auto& win_entries = (l->tournament_match == tourn->get_final_match()) ? final_win_entries : non_final_win_entries; - const auto& lose_entries = (l->tournament_match == tourn->get_final_match()) ? final_lose_entries : non_final_lose_entries; + bool is_final_match = (tourn && (l->tournament_match == tourn->get_final_match())); + const auto& win_entries = is_final_match ? final_win_entries : non_final_win_entries; + const auto& lose_entries = is_final_match ? final_lose_entries : non_final_lose_entries; for (size_t z = 0; z < 10; z++) { ex_cmd.win_entries[z].threshold = win_entries[z].first; ex_cmd.win_entries[z].value = win_entries[z].second; @@ -948,157 +948,253 @@ static bool add_next_tournament_match_client( uint8_t mask_key = (random_object() % 0xFF) + 1; set_mask_for_ep3_game_command(&ex_cmd, sizeof(ex_cmd), mask_key); } - - send_command_t(c, 0xC9, 0x00, state_cmd); send_command_t(c, 0xC9, 0x00, ex_cmd); + s->change_client_lobby(c, l, true, target_client_id); c->flags |= Client::Flag::LOADING; + c->disconnect_hooks.emplace(ADD_NEXT_CLIENT_DISCONNECT_HOOK_NAME, [s, l]() -> void { + add_next_game_client(s, l); + }); + return true; } -static bool start_ep3_tournament_match_if_pending( +static bool start_ep3_battle_table_game_if_pending( shared_ptr s, shared_ptr l, - shared_ptr c, - int16_t table_number) { - auto team = c->ep3_tournament_team.lock(); - if (!team) { - return false; // Client is not registered in a tournament + int16_t table_number, + int16_t tournament_table_number) { + if (table_number < 0) { + // Negative numbers are supposed to mean the client is not seated at a + // table, so it's an error for this function to be called with a negative + // table number + throw logic_error("negative table number"); } - auto tourn = team->tournament.lock(); + + // Figure out which clients are at this table. If any client has declined, we + // never start a match, but we may start a match even if all clients have not + // yet accepted (in case of a tournament match). + unordered_map> table_clients; + bool all_clients_accepted = true; + for (const auto& c : l->clients) { + if (!c || (c->card_battle_table_number != table_number)) { + continue; + } + if (c->card_battle_table_seat_number >= 4) { + throw logic_error("invalid seat number"); + } + if (!table_clients.emplace(c->card_battle_table_seat_number, c).second) { + throw runtime_error("multiple clients in same battle table seat"); + } + if (c->card_battle_table_seat_state == 3) { + return false; + } + if (c->card_battle_table_seat_state != 2) { + all_clients_accepted = false; + } + } + if (table_clients.size() > 4) { + throw runtime_error("too many clients at battle table"); + } + + // Figure out if this is a tournament match setup + unordered_set> tourn_matches; + if (table_number == tournament_table_number) { + for (const auto& it : table_clients) { + auto team = it.second->ep3_tournament_team.lock(); + auto tourn = team ? team->tournament.lock() : nullptr; + auto match = tourn ? tourn->next_match_for_team(team) : nullptr; + // Note: We intentionally don't check for null here. This is to handle the + // case where a tournament-registered player steps into a seat at a table + // where a non-tournament-registered player is already present - we should + // NOT start any match until the non-tournament-registered player leaves, + // or they both accept (and we start a non-tournament match). + tourn_matches.emplace(match); + } + } + + // Get the tournament. Invariant: both tourn_match and tourn are null, or + // neither are null. + auto tourn_match = (tourn_matches.size() == 1) ? *tourn_matches.begin() : nullptr; + auto tourn = tourn_match ? tourn_match->tournament.lock() : nullptr; if (!tourn) { - return false; // The tournament has been canceled + tourn_match.reset(); } - auto match = tourn->next_match_for_team(team); - if (!match) { + + // If this is a tournament match setup, check if all required players are + // present and rearrange their client IDs to match their team positions + unordered_map> game_clients; + if (tourn_match) { + unordered_map required_serial_numbers; + auto add_team_players = [&](shared_ptr team, size_t base_index) -> void { + size_t z = 0; + for (const auto& player : team->players) { + if (z >= 2) { + throw logic_error("more than 2 players on team"); + } + if (player.is_human()) { + required_serial_numbers.emplace(base_index + z, player.serial_number); + } + z++; + } + }; + add_team_players(tourn_match->preceding_a->winner_team, 0); + add_team_players(tourn_match->preceding_b->winner_team, 2); + + for (const auto& it : required_serial_numbers) { + size_t client_id = it.first; + uint32_t serial_number = it.second; + for (const auto& it : table_clients) { + if (it.second->license->serial_number == serial_number) { + game_clients.emplace(client_id, it.second); + } + } + } + + if (game_clients.size() != required_serial_numbers.size()) { + // Not all tournament match participants are present, so we can't start + // the tournament match. (But they can still use the battle table) + tourn_match.reset(); + tourn.reset(); + } else { + // If there is already a game for this match, don't allow a new one to + // start + for (auto l : s->all_lobbies()) { + if (l->tournament_match == tourn_match) { + tourn_match.reset(); + tourn.reset(); + } + } + } + } + + // In the non-tournament case (or if the tournament case was rejected above), + // only start the game if all players have accepted. If they have, just put + // them in the clients map in seat order. + if (!tourn_match) { + if (!all_clients_accepted) { + return false; + } + game_clients = move(table_clients); + } + + // If there are no clients, do nothing (this happens when the last player + // leaves a battle table without starting a game) + if (game_clients.empty()) { return false; } - vector required_serial_numbers; - required_serial_numbers.resize(4, 0); - auto add_team_players = [&](shared_ptr team, size_t base_index) -> void { - size_t z = 0; - for (const auto& player : team->players) { - if (z >= 2) { - throw logic_error("more than 2 players on team"); - } - if (player.is_human()) { - required_serial_numbers.at(base_index + z) = player.serial_number; - } - z++; - } - }; - add_team_players(match->preceding_a->winner_team, 0); - add_team_players(match->preceding_b->winner_team, 2); - - auto clients_map = l->clients_by_serial_number(); - unordered_map> game_clients; - for (size_t z = 0; z < required_serial_numbers.size(); z++) { - uint32_t serial_number = required_serial_numbers[z]; - if (!serial_number) { - continue; - } - shared_ptr player_c; - try { - player_c = clients_map.at(serial_number); - } catch (const out_of_range&) { - return false; - } - if (player_c->card_battle_table_number != table_number) { - return false; - } - game_clients.emplace(z, player_c); - } - - // If there is already a game for this match, do nothing (the player is - // probably about to be pulled into it, when another player is done loading) - for (auto l : s->all_lobbies()) { - if (l->tournament_match == match) { - return false; - } - } - - // At this point, we've checked all the necessary conditions for a tournament - // match to begin. + // At this point, we've checked all the necessary conditions for a game to + // begin. + // Remove all players from the battle table (but don't tell them about this) for (const auto& it : game_clients) { auto other_c = it.second; other_c->card_battle_table_number = -1; other_c->card_battle_table_seat_number = 0; + other_c->disconnect_hooks.erase(BATTLE_TABLE_DISCONNECT_HOOK_NAME); } // If there's only one client in the match, skip the wait phase - they'll be - // added to the match immediately by add_next_tournament_match_client anyway + // added to the match immediately by add_next_game_client anyway if (game_clients.empty()) { - throw logic_error("no clients to add to tournament match"); + throw logic_error("no clients to add to battle table match"); + } else if (game_clients.size() != 1) { for (const auto& it : game_clients) { auto other_c = it.second; send_self_leave_notification(other_c); - string message = string_printf( - "$C7Waiting to begin match in tournament\n$C6%s$C7...\n\n(Hold B+X+START to abort)", - tourn->get_name().c_str()); - send_message_box(other_c, decode_sjis(message)); + u16string message; + if (tourn) { + message = decode_sjis(string_printf( + "$C7Waiting to begin match in tournament\n$C6%s$C7...\n\n(Hold B+X+START to abort)", + tourn->get_name().c_str())); + } else { + message = u"$C7Waiting to begin battle table match...\n\n(Hold B+X+START to abort)"; + } + send_message_box(other_c, message); } } uint32_t flags = Lobby::Flag::NON_V1_ONLY | Lobby::Flag::EPISODE_3_ONLY; - auto game = create_game_generic(s, c, decode_sjis(tourn->get_name()), u"", 0xFF, 0, flags); - game->tournament_match = match; - game->tournament_clients_to_add.clear(); + u16string name = tourn ? decode_sjis(tourn->get_name()) : u""; + auto game = create_game_generic( + s, game_clients.begin()->second, name, u"", 0xFF, 0, flags); + game->tournament_match = tourn_match; + game->clients_to_add.clear(); for (const auto& it : game_clients) { - game->tournament_clients_to_add.emplace(it.first, it.second); + game->clients_to_add.emplace(it.first, it.second); } - add_next_tournament_match_client(s, game); + add_next_game_client(s, game); return true; } +static void on_ep3_battle_table_state_updated( + shared_ptr s, shared_ptr l, int16_t table_number) { + send_ep3_card_battle_table_state(l, table_number); + start_ep3_battle_table_game_if_pending(s, l, table_number, 2); +} + 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 (cmd.seat_number >= 4) { + throw runtime_error("invalid seat number"); + } + 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; - if (cmd.table_number == 2) { - start_ep3_tournament_match_if_pending(s, l, c, cmd.table_number); - } + c->card_battle_table_seat_state = 1; } else { // Leaving battle table c->card_battle_table_number = -1; c->card_battle_table_seat_number = 0; + c->card_battle_table_seat_state = 0; } - send_ep3_card_battle_table_state(l, c->card_battle_table_number); + on_ep3_battle_table_state_updated(s, l, cmd.table_number); 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); + if (should_have_disconnect_hook && !c->disconnect_hooks.count(BATTLE_TABLE_DISCONNECT_HOOK_NAME)) { + c->disconnect_hooks.emplace(BATTLE_TABLE_DISCONNECT_HOOK_NAME, [s, l, c]() -> void { + int16_t table_number = c->card_battle_table_number; + c->card_battle_table_number = -1; + c->card_battle_table_seat_number = 0; + c->card_battle_table_seat_state = 0; + if (table_number != -1) { + on_ep3_battle_table_state_updated(s, l, c->card_battle_table_number); + } }); } else if (!should_have_disconnect_hook) { - c->disconnect_hooks.erase(DISCONNECT_HOOK_NAME); + c->disconnect_hooks.erase(BATTLE_TABLE_DISCONNECT_HOOK_NAME); } } static void on_ep3_battle_table_confirm(shared_ptr s, - shared_ptr c, uint16_t, uint32_t flag, const string& data) { // E4 + shared_ptr c, uint16_t, uint32_t flag, const string& data) { // E5 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) { - // TODO - send_lobby_message_box(c, u"Battle Tables are\nnot yet supported."); + if (c->card_battle_table_number < 0) { + throw runtime_error("invalid table number"); } + + if (flag) { + c->card_battle_table_seat_state = 2; + } else { + c->card_battle_table_seat_state = 3; + } + on_ep3_battle_table_state_updated(s, l, c->card_battle_table_number); } static void on_ep3_counter_state(shared_ptr s, shared_ptr c, @@ -3166,9 +3262,10 @@ static void on_client_ready(shared_ptr s, shared_ptr c, watched_lobby->ep3_server_base->server->send_commands_for_joining_spectator(c->channel); } - // If this is a tournament match and not all players are present, try to bring - // in the next player - add_next_tournament_match_client(s, l); + + // If there are more players to bring in, try to do so + c->disconnect_hooks.erase(ADD_NEXT_CLIENT_DISCONNECT_HOOK_NAME); + add_next_game_client(s, l); } diff --git a/src/SendCommands.cc b/src/SendCommands.cc index e79a05b1..30587344 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -1906,7 +1906,7 @@ void send_ep3_rank_update(shared_ptr c) { void send_ep3_card_battle_table_state(shared_ptr l, uint16_t table_number) { S_CardBattleTableState_GC_Ep3_E4 cmd; for (size_t z = 0; z < 4; z++) { - cmd.entries[z].present = 0; + cmd.entries[z].state = 0; cmd.entries[z].unknown_a1 = 0; cmd.entries[z].guild_card_number = 0; } @@ -1921,10 +1921,10 @@ void send_ep3_card_battle_table_state(shared_ptr l, uint16_t table_number throw runtime_error("invalid battle table seat number"); } auto& e = cmd.entries[c->card_battle_table_seat_number]; - if (e.present) { + if (e.state != 0) { throw runtime_error("multiple clients in the same battle table seat"); } - e.present = 1; + e.state = c->card_battle_table_seat_state; e.guild_card_number = c->license->serial_number; clients.emplace(c); }