From bc017578e30a4c26f0a7d64f67bfcf5d770503fe Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Mon, 11 Dec 2023 12:11:32 -0800 Subject: [PATCH] persist item state when no players are in a game --- README.md | 2 +- src/ChatCommands.cc | 8 ++- src/Client.hh | 1 + src/Lobby.cc | 129 ++++++++++++++++++++++++++++++++- src/Lobby.hh | 1 + src/ReceiveCommands.cc | 146 ++++++-------------------------------- src/ReceiveSubcommands.cc | 11 +-- src/SendCommands.cc | 61 ++++++++++++++-- src/SendCommands.hh | 1 + 9 files changed, 217 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 34e1a621..4fb0b572 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ Some commands only work on the game server and not on the proxy server. The chat * `$qset ` or `$qclear `: Set or clear a global quest flag for everyone in the game. * `$qsync `: Set a quest register's value on your client. `` should be either rXX (e.g. r60) or fXX (e.g. f60); if the latter, `` is parsed as a floating-point value instead of as an integer. * `$gc` (game server only): Send your own Guild Card to yourself. - * `$persist` (game server only): Enable or disable persistence for the current lobby or game. This determines whether the lobby/game is deleted when the last player leaves. You need the DEBUG permission in your user license to use this command because there are no game state checks when you do this. For example, if you make a game persistent, start a quest, then leave the game, the game can't be joined by anyone but also can't be deleted. + * `$persist` (game server only): Enable or disable persistence for the current game. When persistence is on, the game will not be deleted when the last player leaves. The state of enemies and objects on the map will be reset when the last player leaves. * `$sc `: Send a command to yourself. * `$ss ` (proxy server only): Send a command to the remote server. diff --git a/src/ChatCommands.cc b/src/ChatCommands.cc index ed14114c..b57c3eb6 100644 --- a/src/ChatCommands.cc +++ b/src/ChatCommands.cc @@ -477,14 +477,16 @@ static void proxy_command_patch(shared_ptr ses, cons } static void server_command_persist(shared_ptr c, const std::string&) { - check_license_flags(c, License::Flag::DEBUG); auto l = c->require_lobby(); if (l->check_flag(Lobby::Flag::DEFAULT)) { send_text_message(c, "$C6Default lobbies\ncannot be marked\ntemporary"); + } else if (!l->check_flag(Lobby::Flag::GAME)) { + send_text_message(c, "$C6Private lobbies\ncannot be marked\npersistent"); + } else if (l->check_flag(Lobby::Flag::QUEST_IN_PROGRESS) || l->check_flag(Lobby::Flag::JOINABLE_QUEST_IN_PROGRESS)) { + send_text_message(c, "$C6Games cannot be\npersistent if a\nquest has already\nbegun"); } else { l->toggle_flag(Lobby::Flag::PERSISTENT); - send_text_message_printf(c, "Lobby persistence\n%s", - l->check_flag(Lobby::Flag::PERSISTENT) ? "enabled" : "disabled"); + send_text_message_printf(c, "Lobby persistence\n%s", l->check_flag(Lobby::Flag::PERSISTENT) ? "enabled" : "disabled"); } } diff --git a/src/Client.hh b/src/Client.hh index 0fe25343..582f8f09 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -56,6 +56,7 @@ public: USE_OVERRIDE_RANDOM_SEED = 0x0000000020000000, HAS_GUILD_CARD_NUMBER = 0x0000000040000000, AT_BANK_COUNTER = 0x0000000080000000, + SHOULD_SEND_ARTIFICIAL_ITEM_STATE = 0x0001000000000000, // Cheat mode flags SWITCH_ASSIST_ENABLED = 0x0000000100000000, diff --git a/src/Lobby.cc b/src/Lobby.cc index 808055aa..b47692e1 100644 --- a/src/Lobby.cc +++ b/src/Lobby.cc @@ -4,6 +4,7 @@ #include +#include "Compression.hh" #include "Loggers.hh" #include "SendCommands.hh" #include "Text.hh" @@ -104,6 +105,115 @@ void Lobby::create_item_creator() { this->quest ? this->quest->battle_rules : nullptr); } +void Lobby::load_maps() { + auto s = this->require_server_state(); + this->map = make_shared(this->lobby_id); + + if (this->quest) { + auto leader_c = this->clients.at(this->leader_id); + if (!leader_c) { + throw logic_error("lobby leader is missing"); + } + + auto vq = this->quest->version(Version::BB_V4, leader_c->language()); + auto dat_contents = prs_decompress(*vq->dat_contents); + this->map->clear(); + this->map->add_enemies_and_objects_from_quest_data( + this->episode, + this->difficulty, + this->event, + dat_contents.data(), + dat_contents.size(), + this->random_seed, + this->rare_enemy_rates ? this->rare_enemy_rates : Map::NO_RARE_ENEMIES); + if (this->item_creator) { + this->item_creator->clear_destroyed_entities(); + } + + } else { // No quest loaded + for (size_t floor = 0; floor < 0x10; floor++) { + this->log.info("[Map/%zu] Using variations %" PRIX32 ", %" PRIX32, + floor, this->variations[floor * 2].load(), this->variations[floor * 2 + 1].load()); + + auto enemy_filenames = map_filenames_for_variation( + this->episode, + (this->mode == GameMode::SOLO), + floor, + this->variations[floor * 2], + this->variations[floor * 2 + 1], + true); + if (enemy_filenames.empty()) { + this->log.info("[Map/%zu:e] No file to load", floor); + } else { + bool any_map_loaded = false; + for (const string& filename : enemy_filenames) { + try { + auto map_data = s->load_bb_file(filename, "", "map/" + filename); + this->map->add_enemies_from_map_data( + this->episode, + this->difficulty, + this->event, + floor, + map_data->data(), + map_data->size(), + this->rare_enemy_rates); + any_map_loaded = true; + break; + } catch (const exception& e) { + this->log.info("[Map/%zu:e] Failed to load %s: %s", floor, filename.c_str(), e.what()); + } + } + if (!any_map_loaded) { + throw runtime_error(string_printf("no enemy maps loaded for floor %zu", floor)); + } + } + + auto object_filenames = map_filenames_for_variation( + this->episode, + (this->mode == GameMode::SOLO), + floor, + this->variations[floor * 2], + this->variations[floor * 2 + 1], + false); + if (object_filenames.empty()) { + this->log.info("[Map/%zu:o] No file to load", floor); + } else { + bool any_map_loaded = false; + for (const string& filename : object_filenames) { + try { + auto map_data = s->load_bb_file(filename, "", "map/" + filename); + this->map->add_objects_from_map_data(floor, map_data->data(), map_data->size()); + any_map_loaded = true; + break; + } catch (const exception& e) { + this->log.info("[Map/%zu:o] Failed to load %s: %s", floor, filename.c_str(), e.what()); + } + } + if (!any_map_loaded) { + throw runtime_error(string_printf("no object maps loaded for floor %zu", floor)); + } + } + } + } + + this->log.info("Generated objects list (%zu entries):", this->map->objects.size()); + for (size_t z = 0; z < this->map->objects.size(); z++) { + string o_str = this->map->objects[z].str(s->item_name_index); + this->log.info("(K-%zX) %s", z, o_str.c_str()); + } + this->log.info("Generated enemies list (%zu entries):", this->map->enemies.size()); + for (size_t z = 0; z < this->map->enemies.size(); z++) { + string e_str = this->map->enemies[z].str(); + this->log.info("(E-%zX) %s", z, e_str.c_str()); + } + this->log.info("Loaded maps contain %zu object entries and %zu enemy entries overall (%zu as rares)", + this->map->objects.size(), this->map->enemies.size(), this->map->rare_enemy_indexes.size()); + + if (this->item_creator) { + this->item_creator->clear_destroyed_entities(); + } +} + void Lobby::create_ep3_server() { auto s = this->require_server_state(); if (!this->ep3_server) { @@ -212,8 +322,25 @@ void Lobby::add_client(shared_ptr c, ssize_t required_client_id) { } // If the lobby is a game and item tracking is enabled, assign the inventory's - // item IDs + // item IDs. If there was no one else in the lobby, reset all the next item + // IDs also if (this->is_game() && this->check_flag(Lobby::Flag::ITEM_TRACKING_ENABLED)) { + if (leader_index >= this->max_clients) { + for (size_t x = 0; x < 12; x++) { + this->next_item_id[x] = 0x00010000 + 0x00200000 * x; + } + this->next_game_item_id = 0x00810000; + + // Reassign all floor item IDs so they won't conflict with any players' + // item IDs + unordered_map new_item_id_to_floor_item; + for (const auto& it : this->item_id_to_floor_item) { + uint32_t new_item_id = this->generate_item_id(0xFF); + auto& new_fi = new_item_id_to_floor_item.emplace(new_item_id, it.second).first->second; + new_fi.data.id = new_item_id; + } + this->item_id_to_floor_item = std::move(new_item_id_to_floor_item); + } this->assign_inventory_and_bank_item_ids(c); } diff --git a/src/Lobby.hh b/src/Lobby.hh index c6c9ddb8..6a8fae15 100644 --- a/src/Lobby.hh +++ b/src/Lobby.hh @@ -152,6 +152,7 @@ struct Lobby : public std::enable_shared_from_this { std::shared_ptr require_server_state() const; std::shared_ptr require_challenge_params() const; void create_item_creator(); + void load_maps(); void create_ep3_server(); [[nodiscard]] inline bool is_game() const { diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 57d4d59d..282cd51f 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -342,6 +342,14 @@ static void on_1D(shared_ptr c, uint16_t, uint32_t, string&) { } c->game_join_command_queue.reset(); } + + if (c->config.check_flag(Client::Flag::SHOULD_SEND_ARTIFICIAL_ITEM_STATE)) { + c->config.clear_flag(Client::Flag::SHOULD_SEND_ARTIFICIAL_ITEM_STATE); + auto l = c->require_lobby(); + if (!is_ep3(c->version()) && l->check_flag(Lobby::Flag::ITEM_TRACKING_ENABLED)) { + send_artificial_item_state(c); + } + } } static void on_05_XB(shared_ptr c, uint16_t, uint32_t, string&) { @@ -1851,34 +1859,7 @@ static void on_quest_loaded(shared_ptr l) { // For Challenge quests, don't replace the map now - the leader will send an // 02DF command to create overlays, which also replaces the map. if ((l->base_version == Version::BB_V4) && l->map && (l->quest->challenge_template_index < 0)) { - auto leader_c = l->clients.at(l->leader_id); - if (!leader_c) { - throw logic_error("lobby leader is missing"); - } - - auto vq = l->quest->version(Version::BB_V4, leader_c->language()); - auto dat_contents = prs_decompress(*vq->dat_contents); - l->map->clear(); - l->map->add_enemies_and_objects_from_quest_data( - l->episode, - l->difficulty, - l->event, - dat_contents.data(), - dat_contents.size(), - l->random_seed, - l->rare_enemy_rates ? l->rare_enemy_rates : Map::NO_RARE_ENEMIES); - l->item_creator->clear_destroyed_entities(); - - l->log.info("Replaced objects list with quest layout (%zu entries)", l->map->objects.size()); - for (size_t z = 0; z < l->map->objects.size(); z++) { - string o_str = l->map->objects[z].str(s->item_name_index); - l->log.info("(K-%zX) %s", z, o_str.c_str()); - } - l->log.info("Replaced enemies list with quest layout (%zu entries)", l->map->enemies.size()); - for (size_t z = 0; z < l->map->enemies.size(); z++) { - string e_str = l->map->enemies[z].str(); - l->log.info("(E-%zX) %s", z, e_str.c_str()); - } + l->load_maps(); } for (auto& lc : l->clients) { @@ -1925,6 +1906,7 @@ void set_lobby_quest(shared_ptr l, shared_ptr q, bool substi } else { l->set_flag(Lobby::Flag::QUEST_IN_PROGRESS); } + l->clear_flag(Lobby::Flag::PERSISTENT); l->quest = q; if (!is_ep3(l->base_version)) { @@ -2306,6 +2288,16 @@ static void on_10(shared_ptr c, uint16_t, uint32_t, string& data) { throw logic_error("client cannot join game after all preconditions satisfied"); } c->config.set_flag(Client::Flag::LOADING); + // If no one was in the game before, then there's no leader to send the + // item state - send it to the joining player (who is now the leader) + if (game->count_clients() == 1) { + // No one was in the game before, so the object and enemy state is lost; + // regenerate it as if the game was just created + if ((game->base_version == Version::BB_V4) && game->map) { + game->load_maps(); + } + c->config.set_flag(Client::Flag::SHOULD_SEND_ARTIFICIAL_ITEM_STATE); + } break; } @@ -3465,10 +3457,6 @@ static void on_DF_BB(shared_ptr c, uint16_t command, uint32_t, string& d throw runtime_error("challenge template index in quest metadata does not match index sent by client"); } - if (l->item_creator) { - l->item_creator->clear_destroyed_entities(); - } - for (auto lc : l->clients) { if (lc) { lc->use_default_bank(); @@ -3478,16 +3466,7 @@ static void on_DF_BB(shared_ptr c, uint16_t command, uint32_t, string& d } } - auto dat_contents = prs_decompress(*vq->dat_contents); - l->map->clear(); - l->map->add_enemies_and_objects_from_quest_data( - l->episode, - l->difficulty, - l->event, - dat_contents.data(), - dat_contents.size(), - l->random_seed, - l->rare_enemy_rates ? l->rare_enemy_rates : Map::NO_RARE_ENEMIES); + l->load_maps(); break; } @@ -4004,89 +3983,8 @@ shared_ptr create_game_generic( } else { game->rare_enemy_rates = s->rare_enemy_rates_by_difficulty.at(game->difficulty); } - if (game->base_version == Version::BB_V4) { - game->map = make_shared(game->lobby_id); - for (size_t floor = 0; floor < 0x10; floor++) { - c->log.info("[Map/%zu] Using variations %" PRIX32 ", %" PRIX32, - floor, game->variations[floor * 2].load(), game->variations[floor * 2 + 1].load()); - - auto enemy_filenames = map_filenames_for_variation( - game->episode, - is_solo, - floor, - game->variations[floor * 2], - game->variations[floor * 2 + 1], - true); - if (enemy_filenames.empty()) { - c->log.info("[Map/%zu:e] No file to load", floor); - } else { - bool any_map_loaded = false; - for (const string& filename : enemy_filenames) { - try { - auto map_data = s->load_bb_file(filename, "", "map/" + filename); - size_t start_offset = game->map->enemies.size(); - game->map->add_enemies_from_map_data( - game->episode, - game->difficulty, - game->event, - floor, - map_data->data(), - map_data->size(), - game->rare_enemy_rates); - size_t entries_loaded = game->map->enemies.size() - start_offset; - c->log.info("[Map/%zu:e] Loaded %s (%zu entries)", floor, filename.c_str(), entries_loaded); - for (size_t z = start_offset; z < game->map->enemies.size(); z++) { - string e_str = game->map->enemies[z].str(); - static_game_data_log.info("(E-%zX) %s", z, e_str.c_str()); - } - any_map_loaded = true; - break; - } catch (const exception& e) { - c->log.info("[Map/%zu:e] Failed to load %s: %s", floor, filename.c_str(), e.what()); - } - } - if (!any_map_loaded) { - throw runtime_error(string_printf("no enemy maps loaded for floor %zu", floor)); - } - } - - auto object_filenames = map_filenames_for_variation( - game->episode, - is_solo, - floor, - game->variations[floor * 2], - game->variations[floor * 2 + 1], - false); - if (object_filenames.empty()) { - c->log.info("[Map/%zu:o] No file to load", floor); - } else { - bool any_map_loaded = false; - for (const string& filename : object_filenames) { - try { - auto map_data = s->load_bb_file(filename, "", "map/" + filename); - size_t start_offset = game->map->objects.size(); - game->map->add_objects_from_map_data(floor, map_data->data(), map_data->size()); - size_t entries_loaded = game->map->objects.size() - start_offset; - c->log.info("[Map/%zu:o] Loaded %s (%zu entries)", floor, filename.c_str(), entries_loaded); - for (size_t z = start_offset; z < game->map->objects.size(); z++) { - string e_str = game->map->objects[z].str(s->item_name_index); - static_game_data_log.info("(K-%zX) %s", z, e_str.c_str()); - } - any_map_loaded = true; - break; - } catch (const exception& e) { - c->log.info("[Map/%zu:o] Failed to load %s: %s", floor, filename.c_str(), e.what()); - } - } - if (!any_map_loaded) { - throw runtime_error(string_printf("no object maps loaded for floor %zu", floor)); - } - } - } - - c->log.info("Loaded maps contain %zu object entries and %zu enemy entries overall (%zu as rares)", - game->map->objects.size(), game->map->enemies.size(), game->map->rare_enemy_indexes.size()); + game->load_maps(); } return game; } diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index fd474770..c92d7d05 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -2391,7 +2391,6 @@ static void on_battle_restart_bb(shared_ptr c, uint8_t, uint8_t, const v auto new_rules = make_shared(cmd.rules); if (l->item_creator) { l->item_creator->set_restrictions(new_rules); - l->item_creator->clear_destroyed_entities(); } for (auto& lc : l->clients) { @@ -2401,15 +2400,7 @@ static void on_battle_restart_bb(shared_ptr c, uint8_t, uint8_t, const v lc->create_battle_overlay(new_rules, s->level_table); } } - l->map->clear(); - l->map->add_enemies_and_objects_from_quest_data( - l->episode, - l->difficulty, - l->event, - dat_contents.data(), - dat_contents.size(), - l->random_seed, - l->rare_enemy_rates ? l->rare_enemy_rates : Map::NO_RARE_ENEMIES); + l->load_maps(); } } diff --git a/src/SendCommands.cc b/src/SendCommands.cc index 03b3f162..20c8ed53 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -2296,16 +2296,69 @@ void send_ep3_change_music(Channel& ch, uint32_t song) { ch.send(0x60, 0x00, cmd); } -void send_set_player_visibility(shared_ptr l, shared_ptr c, - bool visible) { +void send_set_player_visibility(shared_ptr l, shared_ptr c, bool visible) { uint8_t subcmd = visible ? 0x23 : 0x22; uint16_t client_id = c->lobby_client_id; G_SetPlayerVisibility_6x22_6x23 cmd = {{subcmd, 0x01, client_id}}; send_command_t(l, 0x60, 0x00, cmd); } -//////////////////////////////////////////////////////////////////////////////// -// BB game commands +void send_artificial_item_state(std::shared_ptr c) { + auto l = c->require_lobby(); + if (c->lobby_client_id != l->leader_id) { + throw runtime_error("artificial item state can only be sent to the leader"); + } + if (l->count_clients() != 1) { + throw runtime_error("artificial item state can only be sent with no one else in the game"); + } + + array floor_ws; + + G_SyncItemState_6x6D_Decompressed decompressed_header; + for (size_t z = 0; z < 12; z++) { + decompressed_header.next_item_id_per_player[z] = l->next_item_id[z]; + } + for (const auto& it : l->item_id_to_floor_item) { + const auto& item = it.second; + + FloorItem fi; + fi.floor = item.floor; + fi.from_enemy = 0; + fi.entity_id = 0xFFFF; + fi.x = item.x; + fi.z = item.z; + fi.unknown_a2 = 0; + fi.drop_number = decompressed_header.total_items_dropped_per_floor.at(item.floor)++; + fi.item = item.data; + floor_ws.at(item.floor).put(fi); + + decompressed_header.floor_item_count_per_floor.at(item.floor)++; + } + + StringWriter decompressed_w; + decompressed_w.put(decompressed_header); + for (const auto& floor_w : floor_ws) { + decompressed_w.write(floor_w.str()); + } + + string compressed_data = bc0_compress(decompressed_w.str()); + + G_SyncGameStateHeader_6x6B_6x6C_6x6D_6x6E compressed_header; + compressed_header.header.basic_header.subcommand = 0x6D; + compressed_header.header.basic_header.size = 0x00; + compressed_header.header.basic_header.unused = 0x0000; + compressed_header.header.size = (compressed_data.size() + sizeof(G_SyncGameStateHeader_6x6B_6x6C_6x6D_6x6E) + 3) & (~3); + compressed_header.decompressed_size = decompressed_w.size(); + compressed_header.compressed_size = compressed_data.size(); + + StringWriter w; + w.put(compressed_header); + w.write(compressed_data); + while (w.size() & 3) { + w.put_u8(0x00); + } + send_command(c, 0x6D, c->lobby_client_id, w.str()); +} void send_drop_item(shared_ptr s, Channel& ch, const ItemData& item, bool from_enemy, uint8_t floor, float x, float z, uint16_t entity_id) { diff --git a/src/SendCommands.hh b/src/SendCommands.hh index 9f16284a..2c84c432 100644 --- a/src/SendCommands.hh +++ b/src/SendCommands.hh @@ -298,6 +298,7 @@ void send_ep3_change_music(Channel& ch, uint32_t song); void send_set_player_visibility(std::shared_ptr c, bool visible); void send_revive_player(std::shared_ptr c); +void send_artificial_item_state(std::shared_ptr c); void send_drop_item(std::shared_ptr s, Channel& ch, const ItemData& item, bool from_enemy, uint8_t floor, float x, float z, uint16_t request_id); void send_drop_item(std::shared_ptr l, const ItemData& item,