diff --git a/CMakeLists.txt b/CMakeLists.txt index c9ce0295..b771f642 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,7 @@ add_executable(newserv src/ServerState.cc src/Shell.cc src/StaticGameData.cc + src/TeamIndex.cc src/Text.cc src/TextArchive.cc src/UnicodeTextSet.cc diff --git a/TODO.md b/TODO.md index cdbddeb4..20f121ba 100644 --- a/TODO.md +++ b/TODO.md @@ -36,8 +36,8 @@ - Fix some edge cases on the BB proxy server (e.g. Change Ship) - Implement less-common subcommands - 6xD8: Add S-rank weapon special -- Implement teams - - All EA subcommands - - 6xC1, 6xC2, 6xCD, 6xCE: Team invites/administration - - 6xCC: Exchange item for team points +- Test team commands + - Test all EA subcommands (a few are still not implemented) + - 6xC1, 6xC2, 6xCD, 6xCE: Team invites/administration (not implemented) + - 6xCC: Exchange item for team points (not implemented) - Implement story progress flags for unlocking quests diff --git a/src/Client.cc b/src/Client.cc index 8fa9e023..512947fa 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -263,6 +263,46 @@ shared_ptr Client::require_lobby() const { return l; } +shared_ptr Client::team() { + if (!this->license) { + throw logic_error("Client::team called on client with no license"); + } + + if (this->license->bb_team_id == 0) { + return nullptr; + } + + auto p = this->game_data.character(false); + auto s = this->require_server_state(); + auto team = s->team_index->get_by_id(this->license->bb_team_id); + if (!team) { + this->license->bb_team_id = 0; + this->license->save(); + return nullptr; + } + + auto member_it = team->members.find(this->license->serial_number); + if (member_it == team->members.end()) { + this->license->bb_team_id = 0; + this->license->save(); + return nullptr; + } + + // The team membership is valid, but the player name may be different; update + // the team membership if needed + if (p) { + auto& m = member_it->second; + string name = p->disp.name.decode(this->language()); + if (m.name != name) { + this->log.info("Updating player name in team config"); + m.name = name; + team->save_config(); + } + } + + return team; +} + void Client::dispatch_save_game_data(evutil_socket_t, short, void* ctx) { reinterpret_cast(ctx)->save_game_data(); } diff --git a/src/Client.hh b/src/Client.hh index 0c16b023..9cc920a4 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -17,6 +17,7 @@ #include "PatchFileIndex.hh" #include "Player.hh" #include "QuestScript.hh" +#include "TeamIndex.hh" #include "Text.hh" extern const uint64_t CLIENT_CONFIG_MAGIC; @@ -60,6 +61,7 @@ struct Client : public std::enable_shared_from_this { HAS_EP3_MEDIA_UPDATES = 0x0000000010000000, USE_OVERRIDE_RANDOM_SEED = 0x0000000020000000, HAS_GUILD_CARD_NUMBER = 0x0000000040000000, + ACCEPTED_TEAM_INVITATION = 0x0000000080000000, // Cheat mode flags SWITCH_ASSIST_ENABLED = 0x0000000100000000, @@ -238,6 +240,8 @@ struct Client : public std::enable_shared_from_this { std::shared_ptr require_server_state() const; std::shared_ptr require_lobby() const; + std::shared_ptr team(); + static void dispatch_save_game_data(evutil_socket_t, short, void* ctx); void save_game_data(); static void dispatch_send_ping(evutil_socket_t, short, void* ctx); diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index 529a8303..f272efc0 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -2901,7 +2901,7 @@ struct S_TournamentEntryList_GC_Ep3_E2 { } __packed__; // E2 (S->C): Set system file contents (BB) -// See PSOBBSystemFile in SaveFileFormats.hh for format +// See PSOBBFullSystemFile in SaveFileFormats.hh for format // E3 (S->C): Game or tournament info (Episode 3) // The header.flag argument determines which fields are valid (and which panes @@ -3077,7 +3077,7 @@ struct C_CreateSpectatorTeam_GC_Ep3_E7 { struct SC_SyncSaveFiles_BB_E7 { /* 0000 */ PSOBBCharacterFile char_file; - /* 2EA4 */ PSOBBSystemFile system_file; + /* 2EA4 */ PSOBBFullSystemFile system_file; /* 3994 */ } __packed__; @@ -3223,9 +3223,16 @@ struct C_CreateTeam_BB_01EA { pstring name; } __packed__; -// 02EA (S->C): Unknown -// header.flag must be in the range [0, 6]. If it isn't, the command is ignored. -// No other arguments. +// 02EA (S->C): Create team result +// No arguments except header.flag, which specifies the error code. Values: +// 0 = success +// 1 = generic error +// 2 = name already registered +// 3 = generic error +// 4 = generic error +// 5 = generic error +// 6 = generic error +// Anything else = command is ignored // 03EA (C->S): Add team member @@ -3233,14 +3240,19 @@ struct C_AddOrRemoveTeamMember_BB_03EA_05EA { le_uint32_t guild_card_number = 0; } __packed__; -// 04EA (S->C): Unknown -// No arguments except header.flag. +// 04EA (S->C): Add team member result +// No arguments except header.flag, which specifies the error code. Values: +// 0 = success +// 5 = team is full +// Anything else = generic error // 05EA (C->S): Remove team member // Same format as 03EA. -// 06EA (S->C): Delete team? -// This command behaves exactly like 10EA. +// 06EA (S->C): Remove team member result +// No arguments except header.flag, which specifies the error code. 0 means +// success, but it's not known what any other values mean. The client expects +// the error code to be less than 7. // 07EA: Team chat @@ -3251,20 +3263,19 @@ struct SC_TeamChat_BB_07EA { // Text follows here } __packed__; -// 08EA (C->S): Team admin +// 08EA (C->S): Get team member list // No arguments -// 09EA (S->C): Unknown +// 09EA (S->C): Team member list -struct S_Unknown_BB_09EA { +struct S_TeamMemberList_BB_09EA { le_uint32_t entry_count = 0; - parray unknown_a2; struct Entry { // This is displayed as "<%04d> %s" % (value, message) - le_uint32_t value = 0; - le_uint32_t color = 0; // 0x10 or 0x20 = green, 0x30 = blue, 0x40 = red, anything else = white - le_uint32_t unknown_a1 = 0; - pstring message; + le_uint32_t index = 0; + le_uint32_t privilege_level = 0; // 0x10 or 0x20 = green, 0x30 = blue, 0x40 = red, anything else = white + le_uint32_t guild_card_number = 0; + pstring name; } __packed__; // Variable-length field: // Entry entries[entry_count]; @@ -3291,32 +3302,31 @@ struct S_Unknown_BB_0EEA { // The client also accepts this command but completely ignores it. struct C_SetTeamFlag_BB_0FEA { - parray data; + parray flag_data; } __packed__; -// 10EA: Delete team +// 10EA: Delete team result // No arguments except header.flag -// 11EA: Promote team member +// 11EA: Change team member privilege level // The format below is used only when the client sends this command; when the // server sends it, only header.flag is used. -// TODO: header.flag is used for this command. Figure out what it's for. +// header.flag specifies the new privilege level for the specified team member. -struct C_PromoteTeamMember_BB_11EA { - le_uint32_t unknown_a1 = 0; +struct C_ChangeTeamMemberPrivilegeLevel_BB_11EA { + le_uint32_t guild_card_number = 0; } __packed__; // 12EA (S->C): Team membership information -// If the client is not in a team, all fields except guild_card_number should -// be zero. +// If the client is not in a team, all fields should be zero. struct S_TeamMembershipInformation_BB_12EA { - le_uint32_t unknown_a1 = 0; // Command is ignored unless this is 0 - le_uint32_t guild_card_number = 0; // Team membership ID? + le_uint32_t unknown_a1 = 0; + le_uint32_t guild_card_number = 0; le_uint32_t team_id = 0; le_uint32_t unknown_a4 = 0; - le_uint32_t privilege_level = 0; - uint8_t unknown_a6 = 0; + le_uint32_t unknown_a6 = 0; + uint8_t privilege_level = 0; uint8_t unknown_a7 = 0; uint8_t unknown_a8 = 0; uint8_t unknown_a9 = 0; @@ -3336,7 +3346,7 @@ struct S_TeamInfoForPlayer_BB_13EA_15EA_Entry { le_uint32_t guild_card_number2 = 0; le_uint32_t lobby_client_id = 0; pstring player_name; - parray team_flag; + parray flag_data; } __packed__; // 14EA (C->S): Unknown @@ -3349,32 +3359,51 @@ struct S_TeamInfoForPlayer_BB_13EA_15EA_Entry { // 16EA (S->C): Unknown // No arguments except header.flag. -// 18EA: Membership information -// No arguments (C->S) -// TODO: Document S->C format - -struct S_TeamMembershipInformation_BB_18EA { - parray unknown_a1; - le_uint32_t unknown_a2 = 1; - le_uint32_t unknown_a3 = 1; - le_uint32_t privilege_level = 0; - le_uint32_t guild_card_number = 0; - pstring player_name; - le_uint32_t unknown_a4 = 0; - le_uint32_t unknown_a5 = 2; -} __packed__; - -// 19EA: Privilege list +// 18EA: Team ranking information // No arguments (C->S) -struct S_TeamPrivilegeList_BB_19EA { - le_uint32_t unknown_a1 = 0; +struct S_TeamRankingInformation_BB_18EA { + /* 0000 */ le_uint32_t unknown_a1 = 0; + /* 0004 */ le_uint32_t unknown_a2 = 0; + /* 0008 */ le_uint32_t unknown_a3 = 0; + /* 000C */ le_uint32_t num_entries = 1; + struct Entry { + /* 00 */ le_uint32_t unknown_a1 = 0; + /* 04 */ le_uint32_t privilege_level = 0; + /* 08 */ le_uint32_t guild_card_number = 0; + /* 0C */ pstring player_name; + /* 2C */ le_uint32_t unknown_a2 = 0; + /* 30 */ + } __packed__; + // Variable-length field: + /* 0010 */ // Entry entries[num_entries]; } __packed__; -// 1AEA: Unknown +// 19EA: Team reward list +// No arguments (C->S) -// 1BEA (C->S): Unknown -// header.flag is used, but no other arguments +struct S_TeamRewardList_BB_19EA { + le_uint32_t num_rewards_unlocked = 0; +} __packed__; + +// 1AEA: Team rewards available for purchase + +struct S_TeamRewardsAvailableForPurchase_BB_1AEA { + le_uint32_t num_entries; + struct Entry { + /* 0000 */ pstring name; + /* 0080 */ pstring description; + /* 0180 */ le_uint32_t team_points = 0; + /* 0184 */ le_uint32_t reward_id = 0; + /* 0188 */ + } __packed__; + // Variable length field: + // Entry entries[num_entries]; +} __packed__; + +// 1BEA (C->S): Buy team reward +// No arguments except header.flag, which specifies a reward_id from a preceding +// 1AEA command. // 1CEA: Ranking information // No arguments when sent by the client. @@ -3389,7 +3418,7 @@ struct C_Unknown_BB_1EEA { pstring unknown_a1; } __packed__; -// 1FEA (S->C): Unknown +// 1FEA (S->C): Action result // This command behaves exactly like 02EA. // 20EA: Unknown diff --git a/src/GVMEncoder.hh b/src/GVMEncoder.hh index 4a0e3eb5..fa0a17cf 100644 --- a/src/GVMEncoder.hh +++ b/src/GVMEncoder.hh @@ -43,7 +43,7 @@ constexpr uint16_t encode_xrgb8888_to_xrgb1555(uint32_t xrgb8888) { return ((xrgb8888 >> 9) & 0x7C00) | ((xrgb8888 >> 6) & 0x03E0) | ((xrgb8888 >> 3) & 0x001F); } -constexpr uint32_t encode_rgbx8888_to_xrgb1555(uint16_t rgbx8888) { +constexpr uint16_t encode_rgbx8888_to_xrgb1555(uint32_t rgbx8888) { // In: rrrrrrrrggggggggbbbbbbbbxxxxxxxx // Out: -rrrrrgggggbbbbb return ((rgbx8888 >> 17) & 0x7C00) | ((rgbx8888 >> 14) & 0x03E0) | ((rgbx8888 >> 11) & 0x001F); diff --git a/src/License.cc b/src/License.cc index 7a6e8c51..782b9904 100644 --- a/src/License.cc +++ b/src/License.cc @@ -14,7 +14,8 @@ License::License(const JSON& json) flags(0), ban_end_time(0), ep3_current_meseta(0), - ep3_total_meseta_earned(0) { + ep3_total_meseta_earned(0), + bb_team_id(0) { this->serial_number = json.get_int("SerialNumber"); this->access_key = json.get_string("AccessKey", ""); this->gc_password = json.get_string("GCPassword", ""); @@ -27,6 +28,7 @@ License::License(const JSON& json) this->ban_end_time = json.get_int("BanEndTime", 0); this->ep3_current_meseta = json.get_int("Ep3CurrentMeseta", 0); this->ep3_total_meseta_earned = json.get_int("Ep3TotalMesetaEarned", 0); + this->bb_team_id = json.get_int("BBTeamID", 0); } JSON License::json() const { @@ -43,6 +45,7 @@ JSON License::json() const { {"BanEndTime", this->ban_end_time}, {"Ep3CurrentMeseta", this->ep3_current_meseta}, {"Ep3TotalMesetaEarned", this->ep3_total_meseta_earned}, + {"BBTeamID", this->bb_team_id}, }); } diff --git a/src/License.hh b/src/License.hh index 6d3e8c90..f1207550 100644 --- a/src/License.hh +++ b/src/License.hh @@ -45,6 +45,8 @@ struct License { uint32_t ep3_current_meseta = 0; uint32_t ep3_total_meseta_earned = 0; + uint32_t bb_team_id = 0; + License() = default; explicit License(const JSON& json); diff --git a/src/Menu.hh b/src/Menu.hh index de07bb1d..12608b7d 100644 --- a/src/Menu.hh +++ b/src/Menu.hh @@ -76,6 +76,23 @@ constexpr uint32_t EP3_INFINITE_MESETA = 0xAA0F0FAA; constexpr uint32_t EP3_INFINITE_TIME = 0xAA1010AA; } // namespace ProxyOptionsMenuItemID +namespace TeamRewardMenuItemID { +constexpr uint32_t TEAM_FLAG = 0x01010101; +constexpr uint32_t DRESSING_ROOM = 0x02020202; +constexpr uint32_t MEMBERS_20_LEADERS_3 = 0x03030303; +constexpr uint32_t MEMBERS_40_LEADERS_5 = 0x04040404; +constexpr uint32_t MEMBERS_70_LEADERS_8 = 0x05050505; +constexpr uint32_t MEMBERS_100_LEADERS_10 = 0x06060606; +// constexpr uint32_t POINT_OF_DISASTER = ...; +// constexpr uint32_t TOYS_TWILIGHT = ...; +// constexpr uint32_t COMMANDER_BLADE = ...; +// constexpr uint32_t UNION_GUARD = ...; +// constexpr uint32_t TEAM_POINTS_500 = ...; +// constexpr uint32_t TEAM_POINTS_1000 = ...; +// constexpr uint32_t TEAM_POINTS_5000 = ...; +// constexpr uint32_t TEAM_POINTS_10000 = ...; +} // namespace TeamRewardMenuItemID + struct MenuItem { enum Flag { // For menu items to be visible on DCNTE, they must not have either of the diff --git a/src/Player.cc b/src/Player.cc index de9091d6..aa736a98 100644 --- a/src/Player.cc +++ b/src/Player.cc @@ -130,14 +130,14 @@ void ClientGameData::create_challenge_overlay(GameVersion version, size_t templa } } -shared_ptr ClientGameData::system(bool allow_load) { +shared_ptr ClientGameData::system(bool allow_load) { if (!this->system_data && allow_load) { this->load_all_files(); } return this->system_data; } -shared_ptr ClientGameData::system(bool allow_load) const { +shared_ptr ClientGameData::system(bool allow_load) const { if (!this->system_data.get() && allow_load) { throw runtime_error("system data is not loaded"); } @@ -236,7 +236,7 @@ void ClientGameData::create_character_file( void ClientGameData::load_all_files() { if (this->bb_username.empty()) { - this->system_data.reset(new PSOBBSystemFile()); + this->system_data.reset(new PSOBBBaseSystemFile()); this->character_data.reset(new PSOBBCharacterFile()); this->guild_card_data.reset(new PSOBBGuildCardFile()); return; @@ -248,7 +248,7 @@ void ClientGameData::load_all_files() { string sys_filename = this->system_filename(); if (isfile(sys_filename)) { - this->system_data.reset(new PSOBBSystemFile(load_object_file(sys_filename))); + this->system_data.reset(new PSOBBBaseSystemFile(load_object_file(sys_filename, true))); player_data_log.info("Loaded system data from %s", sys_filename.c_str()); } @@ -272,7 +272,7 @@ void ClientGameData::load_all_files() { // If there was no .psosys file, load the system file from the .psochar // file instead if (!this->system_data) { - this->system_data.reset(new PSOBBSystemFile(freadx(f.get()))); + this->system_data.reset(new PSOBBBaseSystemFile(freadx(f.get()))); player_data_log.info("Loaded system data from %s", char_filename.c_str()); } } @@ -294,7 +294,7 @@ void ClientGameData::load_all_files() { throw runtime_error("account data header is incorrect"); } if (!this->system_data) { - this->system_data.reset(new PSOBBSystemFile(nsa_data->system_file)); + this->system_data.reset(new PSOBBBaseSystemFile(nsa_data->system_file.base)); player_data_log.info("Loaded legacy system data from %s", nsa_filename.c_str()); } if (!this->guild_card_data) { @@ -304,7 +304,7 @@ void ClientGameData::load_all_files() { } if (!this->system_data) { - this->system_data.reset(new PSOBBSystemFile()); + this->system_data.reset(new PSOBBBaseSystemFile()); player_data_log.info("Created new system data"); } if (!this->guild_card_data) { @@ -335,7 +335,6 @@ void ClientGameData::load_all_files() { this->character_data->bank = nsc_data.bank; this->character_data->guild_card.guild_card_number = this->guild_card_number; this->character_data->guild_card.name = nsc_data.disp.name; - this->character_data->guild_card.team_name = this->system_data->team_name; this->character_data->guild_card.description = nsc_data.guild_card_description; this->character_data->guild_card.present = 1; this->character_data->guild_card.language = nsc_data.inventory.language; @@ -399,10 +398,22 @@ void ClientGameData::save_character_file() { string filename = this->character_filename(); auto f = fopen_unique(filename, "wb"); - PSOCommandHeaderBB header = {sizeof(PSOCommandHeaderBB) + sizeof(PSOBBCharacterFile) + sizeof(PSOBBSystemFile), 0x00E7, 0x00000000}; + PSOCommandHeaderBB header = {sizeof(PSOCommandHeaderBB) + sizeof(PSOBBCharacterFile) + sizeof(PSOBBBaseSystemFile) + sizeof(PSOBBTeamMembership), 0x00E7, 0x00000000}; fwritex(f.get(), header); fwritex(f.get(), *this->character_data); fwritex(f.get(), *this->system_data); + // TODO: Technically, we should write the actual team membership struct to the + // file here, but that would cause ClientGameData to depend on License, which + // it currently does not. This data doesn't matter at all for correctness + // within newserv, since it ignores this data entirely and instead generates + // the membership struct from the team ID in the License and the team's state. + // So, writing correct data here would mostly be for compatibility with other + // PSO servers. But if the other server is newserv, then this data would be + // used anyway, and if it's not, then it would presumably have a different set + // of teams with a different set of team IDs anyway, so the membership struct + // here would be useless either way. + static const PSOBBTeamMembership empty_membership; + fwritex(f.get(), empty_membership); player_data_log.info("Saved character file %s", filename.c_str()); } diff --git a/src/Player.hh b/src/Player.hh index fe9e20b8..f0cdda64 100644 --- a/src/Player.hh +++ b/src/Player.hh @@ -64,8 +64,8 @@ public: return this->overlay_character_data.get() != nullptr; } - std::shared_ptr system(bool allow_load = true); - std::shared_ptr system(bool allow_load = true) const; + std::shared_ptr system(bool allow_load = true); + std::shared_ptr system(bool allow_load = true) const; std::shared_ptr character(bool allow_load = true, bool allow_overlay = true); std::shared_ptr character(bool allow_load = true, bool allow_overlay = true) const; @@ -88,7 +88,7 @@ private: // The overlay character data is used in battle and challenge modes, when // character data is temporarily replaced in-game. In other play modes and in // lobbies, overlay_character_data is null. - std::shared_ptr system_data; + std::shared_ptr system_data; std::shared_ptr overlay_character_data; std::shared_ptr character_data; std::shared_ptr guild_card_data; diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 68c9ff06..abb7da1e 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -3312,13 +3312,13 @@ static void on_E7_BB(shared_ptr c, uint16_t, uint32_t, string& data) { p->challenge_records = cmd.char_file.challenge_records; p->battle_records = cmd.char_file.battle_records; p->death_count = cmd.char_file.death_count; - *c->game_data.system() = cmd.system_file; + *c->game_data.system() = cmd.system_file.base; } static void on_E2_BB(shared_ptr c, uint16_t, uint32_t, string& data) { - auto& cmd = check_size_t(data); + auto& cmd = check_size_t(data); auto sys = c->game_data.system(); - *sys = cmd; + *sys = cmd.base; c->game_data.save_system_file(); S_SystemFileCreated_00E1_BB out_cmd = {1}; @@ -4309,15 +4309,323 @@ static void on_EF_Ep3(shared_ptr c, uint16_t, uint32_t, string& data) { send_ep3_card_auction(l); } -static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t, string&) { - // TODO: Implement teams. This command has a very large number of subcommands - // (up to 20EA!). - if (command == 0x01EA) { - send_lobby_message_box(c, "$C6Teams are not supported."); - } else if (command == 0x14EA) { - // Do nothing (for now) - } else { - throw invalid_argument("unimplemented team command"); +static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, string& data) { + auto s = c->require_server_state(); + + switch (command >> 8) { + case 0x01: { // Create team + const auto& cmd = check_size_t(data); + string team_name = cmd.name.decode(c->language()); + if (s->team_index->get_by_name(team_name)) { + send_command(c, 0x02EA, 0x00000002); + } else if (c->license->bb_team_id != 0) { + // TODO: What's the right error code to use here? + send_command(c, 0x02EA, 0x00000001); + } else { + string player_name = c->game_data.character()->disp.name.decode(c->language()); + auto team = s->team_index->create(team_name, c->license->serial_number, player_name); + c->license->bb_team_id = team->team_id; + c->license->save(); + + send_command(c, 0x02EA, 0x00000000); + send_update_team_metadata_for_client(c); + send_team_membership_info(c); + send_update_team_reward_flags(c); + } + break; + } + case 0x03: { // Add team member + auto team = c->team(); + if (team && team->members.at(c->license->serial_number).privilege_level() >= 0x30) { + const auto& cmd = check_size_t(data); + auto s = c->require_server_state(); + shared_ptr added_c; + try { + added_c = s->find_client(nullptr, cmd.guild_card_number); + } catch (const out_of_range&) { + send_lobby_message_box(c, "Player is offline"); + } + + if (added_c) { + auto added_c_team = added_c->team(); + if (added_c_team) { + send_lobby_message_box(c, "Player is already\nin another team"); + } else if (!added_c->config.check_flag(Client::Flag::ACCEPTED_TEAM_INVITATION)) { + send_lobby_message_box(c, "Player did not accept\nteam invitation"); + } else if (!team->can_add_member()) { + // Send "team is full" error + send_command(c, 0x04EA, 0x00000005); + send_command(added_c, 0x04EA, 0x00000005); + } else { + added_c->license->bb_team_id = team->team_id; + added_c->license->save(); + added_c->config.clear_flag(Client::Flag::ACCEPTED_TEAM_INVITATION); + + send_update_team_metadata_for_client(added_c); + send_team_membership_info(added_c); + send_command(c, 0x04EA, 0x00000000); + send_command(added_c, 0x04EA, 0x00000000); + } + } + } + break; + } + case 0x05: { // Remove team member + auto team = c->team(); + if (team) { + const auto& cmd = check_size_t(data); + bool is_removing_self = (cmd.guild_card_number == c->license->serial_number); + if (is_removing_self || + (team->members.at(c->license->serial_number).privilege_level() >= 0x30)) { + s->team_index->remove_member(cmd.guild_card_number); + auto removed_license = s->license_index->get(cmd.guild_card_number); + removed_license->bb_team_id = 0; + removed_license->save(); + send_command(c, 0x06EA, 0x00000000); + + shared_ptr removed_c; + if (is_removing_self) { + removed_c = c; + } else { + try { + removed_c = s->find_client(nullptr, cmd.guild_card_number); + } catch (const out_of_range&) { + } + } + if (removed_c) { + send_update_team_metadata_for_client(removed_c); + send_team_membership_info(removed_c); + } + } else { + // TODO: Figure out the right error code to use here. + send_command(c, 0x06EA, 0x00000001); + } + } + break; + } + case 0x07: { // Team chat + auto team = c->team(); + if (team) { + check_size_v(data.size(), sizeof(SC_TeamChat_BB_07EA) + 4); + static const string required_end("\0\0", 2); + if (ends_with(data, required_end)) { + for (const auto& it : team->members) { + try { + auto target_c = s->find_client(nullptr, it.second.serial_number); + send_command(target_c, 0x07EA, 0x00000000, data); + } catch (const out_of_range&) { + } + } + } + } + break; + } + case 0x08: + send_team_member_list(c); + break; + case 0x0D: { + auto team = c->team(); + if (team) { + S_Unknown_BB_0EEA cmd; + cmd.team_name.encode(team->name, c->language()); + send_command_t(c, 0x0EEA, 0x00000000, cmd); + } else { + throw runtime_error("client is not in a team"); + } + break; + } + case 0x0F: { // Set team flag + auto team = c->team(); + if (team && team->members.at(c->license->serial_number).check_flag(TeamIndex::Team::Member::Flag::IS_MASTER)) { + const auto& cmd = check_size_t(data); + team->flag_data.reset(new parray(cmd.flag_data)); + team->save_flag(); + for (const auto& it : team->members) { + try { + auto member_c = s->find_client(nullptr, it.second.serial_number); + send_update_team_metadata_for_client(member_c); + } catch (const out_of_range&) { + } + } + } + break; + } + case 0x10: { // Disband team + auto team = c->team(); + if (team && team->members.at(c->license->serial_number).check_flag(TeamIndex::Team::Member::Flag::IS_MASTER)) { + s->team_index->disband(team->team_id); + + send_command(c, 0x10EA, 0x00000000); + for (const auto& it : team->members) { + try { + auto member_c = s->find_client(nullptr, it.second.serial_number); + send_update_team_metadata_for_client(member_c); + send_team_membership_info(member_c); + } catch (const out_of_range&) { + } + } + } + break; + } + case 0x11: { // Change member privilege level + auto team = c->team(); + if (team) { + auto& cmd = check_size_t(data); + if (cmd.guild_card_number == c->license->serial_number) { + throw runtime_error("this command cannot be used to modify your own permissions"); + } + + auto& this_m = team->members.at(c->license->serial_number); + if (!this_m.check_flag(TeamIndex::Team::Member::Flag::IS_MASTER)) { + break; + } + auto& other_m = team->members.at(cmd.guild_card_number); + + // The client only sends this command with flag = 0x00, 0x30, or 0x40 + bool send_updates_for_this_m = false; + bool send_updates_for_other_m = false; + switch (flag) { + case 0x00: // Demote member + if (other_m.check_flag(TeamIndex::Team::Member::Flag::IS_LEADER)) { + other_m.clear_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + send_updates_for_other_m = true; + } else { + send_command(c, 0x11EA, 0x00000005); + } + break; + case 0x30: // Promote member + if (!other_m.check_flag(TeamIndex::Team::Member::Flag::IS_LEADER) && team->can_promote_leader()) { + other_m.set_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + send_updates_for_other_m = true; + } else { + send_command(c, 0x11EA, 0x00000005); + } + break; + case 0x40: // Transfer master + this_m.clear_flag(TeamIndex::Team::Member::Flag::IS_MASTER); + this_m.set_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + other_m.clear_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + other_m.set_flag(TeamIndex::Team::Member::Flag::IS_MASTER); + send_updates_for_this_m = true; + send_updates_for_other_m = true; + break; + default: + throw runtime_error("invalid privilege level"); + } + + if (send_updates_for_this_m) { + send_update_team_metadata_for_client(c); + send_team_membership_info(c); + } + if (send_updates_for_other_m) { + try { + auto other_c = s->find_client(nullptr, cmd.guild_card_number); + send_update_team_metadata_for_client(c); + send_team_membership_info(c); + } catch (const out_of_range&) { + } + } + } + break; + } + case 0x13: + send_all_nearby_team_metadatas_to_client(c, true); + break; + case 0x14: + break; + case 0x18: // Ranking information + send_team_rank_info(c); + break; + case 0x19: { + S_TeamRewardList_BB_19EA cmd = {0}; + send_command_t(c, 0x19EA, 0x00000000, cmd); + break; + } + case 0x1A: // Get team rewards available for purchase + send_team_rewards_available_for_purchase(c); + break; + case 0x1B: { // Buy team reward + auto team = c->team(); + if (team) { + check_size_v(data.size(), 0); // No data should be sent + bool should_send_update_reward_flags = false; + switch (flag) { + case TeamRewardMenuItemID::TEAM_FLAG: + team->set_reward_flag(TeamIndex::Team::RewardFlag::TEAM_FLAG); + should_send_update_reward_flags = true; + break; + case TeamRewardMenuItemID::DRESSING_ROOM: + team->set_reward_flag(TeamIndex::Team::RewardFlag::DRESSING_ROOM); + should_send_update_reward_flags = true; + break; + case TeamRewardMenuItemID::MEMBERS_20_LEADERS_3: + if (team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_20_LEADERS_3) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_40_LEADERS_5) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_70_LEADERS_8) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_100_LEADERS_10)) { + throw runtime_error("team size upgrades purchased out of order"); + } + team->set_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_20_LEADERS_3); + should_send_update_reward_flags = true; + break; + case TeamRewardMenuItemID::MEMBERS_40_LEADERS_5: + if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_20_LEADERS_3) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_40_LEADERS_5) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_70_LEADERS_8) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_100_LEADERS_10)) { + throw runtime_error("team size upgrades purchased out of order"); + } + team->set_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_40_LEADERS_5); + should_send_update_reward_flags = true; + break; + case TeamRewardMenuItemID::MEMBERS_70_LEADERS_8: + if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_20_LEADERS_3) || + !team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_40_LEADERS_5) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_70_LEADERS_8) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_100_LEADERS_10)) { + throw runtime_error("team size upgrades purchased out of order"); + } + team->set_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_70_LEADERS_8); + should_send_update_reward_flags = true; + break; + case TeamRewardMenuItemID::MEMBERS_100_LEADERS_10: + if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_20_LEADERS_3) || + !team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_40_LEADERS_5) || + !team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_70_LEADERS_8) || + team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_100_LEADERS_10)) { + throw runtime_error("team size upgrades purchased out of order"); + } + team->set_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_100_LEADERS_10); + should_send_update_reward_flags = true; + break; + // TODO: Implement all the following cases + // case POINT_OF_DISASTER: + // case TOYS_TWILIGHT: + // case COMMANDER_BLADE: + // case UNION_GUARD: + // case TEAM_POINTS_500: + // case TEAM_POINTS_1000: + // case TEAM_POINTS_5000: + // case TEAM_POINTS_10000: + default: + throw runtime_error("incorrect team reward ID"); + } + if (should_send_update_reward_flags) { + for (const auto& it : team->members) { + try { + auto member_c = s->find_client(nullptr, it.second.serial_number); + send_update_team_reward_flags(member_c); + } catch (const out_of_range&) { + } + } + } + } + break; + } + case 0x1C: + throw runtime_error("team subcommand is not yet implemented"); + default: + throw runtime_error("invalid team command"); } } diff --git a/src/SaveFileFormats.cc b/src/SaveFileFormats.cc index 5b9900a6..fa13235c 100644 --- a/src/SaveFileFormats.cc +++ b/src/SaveFileFormats.cc @@ -197,20 +197,14 @@ uint32_t PSOBBGuildCardFile::checksum() const { return crc32(this, sizeof(*this)); } -PSOBBSystemFile::PSOBBSystemFile() - : guild_card_number(0), - team_id(0), - team_info(0), - team_privilege_level(0), - reserved(0), - team_rewards(0) { +PSOBBBaseSystemFile::PSOBBBaseSystemFile() { // This field is based on 1/1/2000, not 1/1/1970, so adjust appropriately this->base.creation_timestamp = (now() - 946684800000000ULL) / 1000000; - for (size_t z = 0; z < PSOBBSystemFile::DEFAULT_KEY_CONFIG.size(); z++) { - this->key_config[z] = PSOBBSystemFile::DEFAULT_KEY_CONFIG[z]; + for (size_t z = 0; z < PSOBBBaseSystemFile::DEFAULT_KEY_CONFIG.size(); z++) { + this->key_config[z] = PSOBBBaseSystemFile::DEFAULT_KEY_CONFIG[z]; } - for (size_t z = 0; z < PSOBBSystemFile::DEFAULT_JOYSTICK_CONFIG.size(); z++) { - this->joystick_config[z] = PSOBBSystemFile::DEFAULT_JOYSTICK_CONFIG[z]; + for (size_t z = 0; z < PSOBBBaseSystemFile::DEFAULT_JOYSTICK_CONFIG.size(); z++) { + this->joystick_config[z] = PSOBBBaseSystemFile::DEFAULT_JOYSTICK_CONFIG[z]; } } @@ -448,7 +442,7 @@ const std::array PSOBBCharacterFile::DEFAULT_TECH_MENU_CONFIG = { 0x0000, 0x0006, 0x0003, 0x0001, 0x0007, 0x0004, 0x0002, 0x0008, 0x0005, 0x0009, 0x0012, 0x000F, 0x0010, 0x0011, 0x000D, 0x000A, 0x000B, 0x000C, 0x000E, 0x0000}; -const std::array PSOBBSystemFile::DEFAULT_KEY_CONFIG = { +const std::array PSOBBBaseSystemFile::DEFAULT_KEY_CONFIG = { 0x00, 0x00, 0x00, 0x00, 0x5E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -473,7 +467,7 @@ const std::array PSOBBSystemFile::DEFAULT_KEY_CONFIG = { 0x00, 0x00, 0x00, 0x00, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00}; -const std::array PSOBBSystemFile::DEFAULT_JOYSTICK_CONFIG = { +const std::array PSOBBBaseSystemFile::DEFAULT_JOYSTICK_CONFIG = { 0x00, 0x01, 0xFF, 0xFF, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, diff --git a/src/SaveFileFormats.hh b/src/SaveFileFormats.hh index daacab58..d9694e9c 100644 --- a/src/SaveFileFormats.hh +++ b/src/SaveFileFormats.hh @@ -119,7 +119,7 @@ struct PSOGCEp3SystemFile { /* 012C */ } __attribute__((packed)); -struct PSOBBSystemFileBase { +struct PSOBBMinimalSystemFile { /* 0000 */ be_uint32_t checksum = 0; /* 0004 */ be_int16_t music_volume = 0; /* 0006 */ int8_t sound_volume = 0; @@ -132,24 +132,41 @@ struct PSOBBSystemFileBase { /* 0114 */ } __attribute__((packed)); -struct PSOBBSystemFile { - /* 0000 */ PSOBBSystemFileBase base; +struct PSOBBTeamMembership { + /* 0000 */ le_uint32_t guild_card_number = 0; + /* 0004 */ le_uint32_t team_id = 0; + /* 0008 */ le_uint32_t unknown_a4 = 0; + /* 000C */ le_uint32_t unknown_a6 = 0; + /* 0010 */ uint8_t privilege_level = 0; + /* 0011 */ uint8_t unknown_a7 = 0; + /* 0012 */ uint8_t unknown_a8 = 0; + /* 0013 */ uint8_t unknown_a9 = 0; + /* 0014 */ pstring team_name; + /* 0034 */ parray flag_data; + /* 0834 */ le_uint32_t reward_flags = 0; + /* 0838 */ + + PSOBBTeamMembership() = default; +} __attribute__((packed)); + +struct PSOBBBaseSystemFile { + /* 0000 */ PSOBBMinimalSystemFile base; /* 0114 */ parray key_config; /* 0280 */ parray joystick_config; - /* 02B8 */ le_uint32_t guild_card_number = 0; - /* 02BC */ le_uint32_t team_id = 0; - /* 02C0 */ le_uint64_t team_info = 0; - /* 02C8 */ le_uint16_t team_privilege_level = 0; - /* 02CA */ le_uint16_t reserved = 0; - /* 02CC */ pstring team_name; - /* 02EC */ parray team_flag; - /* 0AEC */ le_uint32_t team_rewards = 0; - /* 0AF0 */ + /* 02B8 */ static const std::array DEFAULT_KEY_CONFIG; static const std::array DEFAULT_JOYSTICK_CONFIG; - PSOBBSystemFile(); + PSOBBBaseSystemFile(); +} __attribute__((packed)); + +struct PSOBBFullSystemFile { + /* 0000 */ PSOBBBaseSystemFile base; + /* 02B8 */ PSOBBTeamMembership team_membership; + /* 0AF0 */ + + PSOBBFullSystemFile() = default; } __attribute__((packed)); struct PSOBBCharacterFile { @@ -242,7 +259,7 @@ struct PSOBBGuildCardFile { void clear(); } __attribute__((packed)); - /* 0000 */ PSOBBSystemFileBase system_file; + /* 0000 */ PSOBBMinimalSystemFile system_file; /* 0114 */ parray blocked; /* 1DF4 */ parray unknown_a2; /* 1F74 */ parray entries; @@ -758,7 +775,7 @@ struct LegacySavedAccountDataBB { // .nsa file format /* 0000 */ pstring signature; /* 0040 */ parray blocked_senders; /* 00B8 */ PSOBBGuildCardFile guild_card_file; - /* D648 */ PSOBBSystemFile system_file; + /* D648 */ PSOBBFullSystemFile system_file; /* E138 */ le_uint32_t unused; /* E13C */ le_uint32_t option_flags; /* E140 */ parray shortcuts; diff --git a/src/SendCommands.cc b/src/SendCommands.cc index 4a537d9d..4accc30a 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -470,7 +470,14 @@ void send_client_init_bb(shared_ptr c, uint32_t error_code) { } void send_system_file_bb(shared_ptr c) { - send_command_t(c, 0x00E2, 0x00000000, *c->game_data.system()); + auto team = c->team(); + + PSOBBFullSystemFile cmd; + cmd.base = *c->game_data.system(); + if (team) { + cmd.team_membership = team->membership_for_member(c->license->serial_number); + } + send_command_t(c, 0x00E2, 0x00000000, cmd); } void send_player_preview_bb(shared_ptr c, int8_t character_index, const PlayerDispDataBBPreview* preview) { @@ -596,12 +603,19 @@ void send_approve_player_choice_bb(shared_ptr c) { void send_complete_player_bb(shared_ptr c) { auto p = c->game_data.character(true, false); auto sys = c->game_data.system(true); + auto team = c->team(); if (c->config.check_flag(Client::Flag::FORCE_ENGLISH_LANGUAGE_BB)) { p->inventory.language = 1; p->guild_card.language = 1; sys->base.language = 1; } - SC_SyncSaveFiles_BB_E7 cmd = {*p, *sys}; + + SC_SyncSaveFiles_BB_E7 cmd; + cmd.char_file = *p; + cmd.system_file.base = *sys; + if (team) { + cmd.system_file.team_membership = team->membership_for_member(c->license->serial_number); + } send_command_t(c, 0x00E7, 0x00000000, cmd); } @@ -1103,17 +1117,22 @@ void send_guild_card(shared_ptr c, shared_ptr source) { } auto source_p = source->game_data.character(true, false); - uint32_t guild_card_number = source->license->serial_number; + auto source_team = source->team(); + uint64_t xb_user_id = source->license->xb_user_id ? source->license->xb_user_id - : (0xAE00000000000000 | guild_card_number); - uint8_t language = source_p->inventory.language; - string name = source_p->disp.name.decode(language); - string description = source_p->guild_card.description.decode(language); - uint8_t section_id = source_p->disp.visual.section_id; - uint8_t char_class = source_p->disp.visual.char_class; + : (0xAE00000000000000ULL | source->license->serial_number); - send_guild_card(c->channel, guild_card_number, xb_user_id, name, "", description, language, section_id, char_class); + send_guild_card( + c->channel, + source->license->serial_number, + xb_user_id, + source_p->disp.name.decode(source->language()), + source_team ? source_team->name : "", + source_p->guild_card.description.decode(source->language()), + source->language(), + source_p->disp.visual.section_id, + source_p->disp.visual.char_class); } //////////////////////////////////////////////////////////////////////////////// @@ -1719,6 +1738,10 @@ void send_join_lobby_t(shared_ptr c, shared_ptr l, shared_ptr(c, l, joining_client); } + if (c->version() == GameVersion::BB) { + send_all_nearby_team_metadatas_to_client(c, false); + } + uint8_t lobby_type; if (c->config.override_lobby_number != 0x80) { lobby_type = c->config.override_lobby_number; @@ -3158,3 +3181,216 @@ void send_change_event(shared_ptr s, uint8_t new_event) { send_change_event(l, new_event); } } + +//////////////////////////////////////////////////////////////////////////////// +// BB teams + +void send_team_membership_info(shared_ptr c) { + auto team = c->team(); + S_TeamMembershipInformation_BB_12EA cmd; + if (team) { + cmd.guild_card_number = c->license->serial_number; + cmd.team_id = team->team_id; + cmd.privilege_level = team->members.at(c->license->serial_number).privilege_level(); + cmd.team_name.encode(team->name); + } + send_command_t(c, 0x12EA, 0x00000000, cmd); +} + +static S_TeamInfoForPlayer_BB_13EA_15EA_Entry team_metadata_for_client(shared_ptr c) { + auto team = c->team(); + S_TeamInfoForPlayer_BB_13EA_15EA_Entry cmd; + cmd.lobby_client_id = c->lobby_client_id; + if (team) { + cmd.guild_card_number = c->license->serial_number; + cmd.team_id = team->team_id; + cmd.privilege_level = team->members.at(c->license->serial_number).privilege_level(); + cmd.team_name.encode(team->name); + cmd.guild_card_number2 = c->license->serial_number; + cmd.player_name = c->game_data.character()->disp.name; + if (team->flag_data) { + cmd.flag_data = *team->flag_data; + } + } + return cmd; +} + +void send_update_team_metadata_for_client(shared_ptr c) { + auto l = c->require_lobby(); + send_command_t(l, 0x15EA, 0x00000001, team_metadata_for_client(c)); +} + +void send_all_nearby_team_metadatas_to_client(shared_ptr c, bool is_13EA) { + auto l = c->require_lobby(); + + vector entries; + entries.reserve(l->count_clients()); + for (auto lc : l->clients) { + if (lc) { + entries.emplace_back(team_metadata_for_client(lc)); + } + } + send_command_vt(l, is_13EA ? 0x13EA : 0x15EA, entries.size(), entries); +} + +void send_update_team_reward_flags(std::shared_ptr c) { + auto team = c->team(); + send_command(c, 0x1DEA, team ? team->reward_flags : 0x00000000); +} + +void send_team_member_list(shared_ptr c) { + auto team = c->team(); + if (!team) { + throw runtime_error("client is not in a team"); + } + + S_TeamMemberList_BB_09EA header; + header.entry_count = team->members.size(); + + vector entries; + entries.reserve(header.entry_count); + for (auto& it : team->members) { + auto& m = it.second; + auto& e = entries.emplace_back(); + e.index = entries.size(); + e.privilege_level = m.privilege_level(); + e.guild_card_number = m.serial_number; + e.name.encode(m.name, c->language()); + } + + send_command_t_vt(c, 0x09EA, 0x00000000, header, entries); +} + +void send_team_rank_info(std::shared_ptr c) { + auto team = c->team(); + if (!team) { + throw runtime_error("client is not in a team"); + } + + S_TeamRankingInformation_BB_18EA cmd; + cmd.num_entries = team->num_members(); + + vector entries; + auto& e1 = entries.emplace_back(); + e1.unknown_a1 = 1; + e1.privilege_level = 0x00; + e1.guild_card_number = 0x55555555; + e1.player_name.encode("TeamRappy"); + auto& e2 = entries.emplace_back(); + e2.unknown_a1 = 2; + e2.privilege_level = 0x30; + e2.guild_card_number = 0x66666666; + e2.player_name.encode("TeamRappy"); + auto& e3 = entries.emplace_back(); + e3.unknown_a1 = 3; + e3.privilege_level = 0x40; + e3.guild_card_number = 0x77777777; + e3.player_name.encode("TeamRappy"); + // TODO NOCOMMIT: write this function for realz + + send_command_t_vt(c, 0x18EA, 0x00000000, cmd, entries); +} + +void send_team_rewards_available_for_purchase(std::shared_ptr c) { + auto team = c->team(); + if (!team) { + throw runtime_error("user is not in a team"); + } + + vector entries; + if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::TEAM_FLAG)) { + auto& e = entries.emplace_back(); + e.name.encode("Team flag"); + e.description.encode("Show a custom banner\nabove your team\'s\nplayers in the lobby"); + e.reward_id = TeamRewardMenuItemID::TEAM_FLAG; + e.team_points = 2500; + } + if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::DRESSING_ROOM)) { + auto& e = entries.emplace_back(); + e.name.encode("Dressing room"); + e.description.encode("Unlock the ability to\nchange your character\'s\nappearance"); + e.reward_id = TeamRewardMenuItemID::DRESSING_ROOM; + e.team_points = 3000; + } + if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_20_LEADERS_3)) { + auto& e = entries.emplace_back(); + e.name.encode("20 team members"); + e.description.encode("Increase your team\'s\nsize limit to 30 members\nand 3 leaders"); + e.reward_id = TeamRewardMenuItemID::MEMBERS_20_LEADERS_3; + e.team_points = 1500; + } else if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_40_LEADERS_5)) { + auto& e = entries.emplace_back(); + e.name.encode("40 team members"); + e.description.encode("Increase your team\'s\nsize limit to 40 members\nand 3 leaders"); + e.reward_id = TeamRewardMenuItemID::MEMBERS_40_LEADERS_5; + e.team_points = 4000; + } else if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_70_LEADERS_8)) { + auto& e = entries.emplace_back(); + e.name.encode("70 team members"); + e.description.encode("Increase your team\'s\nsize limit to 70 members\nand 8 leaders"); + e.reward_id = TeamRewardMenuItemID::MEMBERS_70_LEADERS_8; + e.team_points = 9000; + } else if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::MEMBERS_100_LEADERS_10)) { + auto& e = entries.emplace_back(); + e.name.encode("100 team members"); + e.description.encode("Increase your team\'s\nsize limit to 100 members\nand 10 leaders"); + e.reward_id = TeamRewardMenuItemID::MEMBERS_100_LEADERS_10; + e.team_points = 18000; + } + + // TODO: Implement these. Currently we don't have a good way to conditionally + // unlock quests, and especially not from a team reward flag. + // if (!point_of_disaster_unlocked) { + // auto& e = entries.emplace_back(); + // e.name.encode("Quest: Point of Disaster"); + // e.description.encode("Unlock the quest\nPoint of Disaster\nfor your team"); + // e.team_points = 1000; + // e.reward_id = TeamRewardMenuItemID::POINT_OF_DISASTER; + // } + // if (!toys_twilight_unlocked) { + // auto& e = entries.emplace_back(); + // e.name.encode("Quest: Toys Twilight"); + // e.description.encode("Unlock the quest\nToys Twilight\nfor your team"); + // e.team_points = 1000; + // e.reward_id = TeamRewardMenuItemID::TOYS_TWILIGHT; + // } + + // TODO: How should these be implemented? There has to be a way to create + // items in the lobby, presumably... + // auto& e = entries.emplace_back(); + // e.name.encode("Commander Blade"); + // e.description.encode("Create a Commander\nBlade weapon"); + // e.team_points = 8000; + // e.reward_id = TeamRewardMenuItemID::COMMANDER_BLADE; + // auto& e = entries.emplace_back(); + // e.name.encode("Union Guard"); + // e.description.encode("Create a Union Guard\nshield"); + // e.team_points = 100; + // e.reward_id = TeamRewardMenuItemID::UNION_GUARD; + + // auto& e = entries.emplace_back(); + // e.name.encode("Team Points Ticket 500"); + // e.description.encode("Create a 500-point ticket"); + // e.team_points = 500; + // e.reward_id = TeamRewardMenuItemID::TEAM_POINTS_500; + // auto& e = entries.emplace_back(); + // e.name.encode("Team Points Ticket 1000"); + // e.description.encode("Create a 1000-point ticket"); + // e.team_points = 1000; + // e.reward_id = TeamRewardMenuItemID::TEAM_POINTS_1000; + // auto& e = entries.emplace_back(); + // e.name.encode("Team Points Ticket 5000"); + // e.description.encode("Create a 5000-point ticket"); + // e.team_points = 5000; + // e.reward_id = TeamRewardMenuItemID::TEAM_POINTS_5000; + // auto& e = entries.emplace_back(); + // e.name.encode("Team Points Ticket 10000"); + // e.description.encode("Create a 10000-point ticket"); + // e.team_points = 10000; + // e.reward_id = TeamRewardMenuItemID::TEAM_POINTS_10000; + + S_TeamRewardsAvailableForPurchase_BB_1AEA cmd; + cmd.num_entries = entries.size(); + + send_command_t_vt(c, 0x1AEA, 0x00000000, cmd, entries); +} diff --git a/src/SendCommands.hh b/src/SendCommands.hh index 5f26af20..c6410abf 100644 --- a/src/SendCommands.hh +++ b/src/SendCommands.hh @@ -383,3 +383,11 @@ void send_server_time(std::shared_ptr c); void send_change_event(std::shared_ptr c, uint8_t new_event); void send_change_event(std::shared_ptr l, uint8_t new_event); void send_change_event(std::shared_ptr s, uint8_t new_event); + +void send_team_membership_info(std::shared_ptr c); // 12EA +void send_update_team_metadata_for_client(std::shared_ptr c); // 15EA (to all clients in lobby, with only c's data) +void send_all_nearby_team_metadatas_to_client(std::shared_ptr c, bool is_13EA); // 13EA/15EA (to only c, with all lobby clients' data) +void send_update_team_reward_flags(std::shared_ptr c); // 1DEA +void send_team_member_list(std::shared_ptr c); // 09EA +void send_team_rank_info(std::shared_ptr c); // 18EA +void send_team_rewards_available_for_purchase(std::shared_ptr c); // 1AEA diff --git a/src/ServerShell.cc b/src/ServerShell.cc index 47474e21..272d1bad 100644 --- a/src/ServerShell.cc +++ b/src/ServerShell.cc @@ -288,6 +288,8 @@ Proxy session commands:\n\ for (const string& type : types) { if (type == "licenses") { this->state->load_licenses(); + } else if (type == "teams") { + this->state->load_teams(); } else if (type == "patches") { this->state->load_patch_indexes(); } else if (type == "battle-params") { diff --git a/src/ServerState.cc b/src/ServerState.cc index c9fe8468..d291a95d 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -98,6 +98,7 @@ void ServerState::init() { this->parse_config(config, false); this->load_bb_private_keys(); this->load_licenses(); + this->load_teams(); this->load_patch_indexes(); this->load_battle_params(); this->load_level_table(); @@ -285,7 +286,6 @@ shared_ptr ServerState::find_client(const std::string* identifier, uint6 } } - // look in the current lobby first if (l) { try { return l->find_client(identifier, serial_number); @@ -293,7 +293,6 @@ shared_ptr ServerState::find_client(const std::string* identifier, uint6 } } - // look in all lobbies if not found for (auto& other_l : this->all_lobbies()) { if (l == other_l) { continue; // don't bother looking again @@ -887,10 +886,15 @@ void ServerState::load_bb_private_keys() { } void ServerState::load_licenses() { - config_log.info("Loading license list"); + config_log.info("Indexing licenses"); this->license_index.reset(new LicenseIndex()); } +void ServerState::load_teams() { + config_log.info("Indexing teams"); + this->team_index.reset(new TeamIndex("system/teams")); +} + void ServerState::load_patch_indexes() { if (isdir("system/patch-pc")) { config_log.info("Indexing PSO PC patch files"); diff --git a/src/ServerState.hh b/src/ServerState.hh index 9423ff77..45187134 100644 --- a/src/ServerState.hh +++ b/src/ServerState.hh @@ -22,6 +22,7 @@ #include "Lobby.hh" #include "Menu.hh" #include "Quest.hh" +#include "TeamIndex.hh" #include "WordSelectTable.hh" // Forward declarations due to reference cycles @@ -141,6 +142,7 @@ struct ServerState : public std::enable_shared_from_this { std::vector ep3_lobby_banners; std::shared_ptr license_index; + std::shared_ptr team_index; std::shared_ptr information_menu_v2; std::shared_ptr information_menu_v3; @@ -232,6 +234,7 @@ struct ServerState : public std::enable_shared_from_this { void parse_config(const JSON& config_json, bool is_reload); void load_bb_private_keys(); void load_licenses(); + void load_teams(); void load_patch_indexes(); void load_battle_params(); void load_level_table(); diff --git a/src/TeamIndex.cc b/src/TeamIndex.cc new file mode 100644 index 00000000..5e3c88f3 --- /dev/null +++ b/src/TeamIndex.cc @@ -0,0 +1,326 @@ +#include "TeamIndex.hh" + +#include +#include +#include + +#include "BattleParamsIndex.hh" +#include "GVMEncoder.hh" +#include "ItemData.hh" +#include "Loggers.hh" +#include "StaticGameData.hh" + +using namespace std; + +TeamIndex::Team::Member::Member(const JSON& json) + : serial_number(json.get_int("SerialNumber")), + flags(json.get_int("Flags", 0)), + points(json.get_int("Points", 0)), + name(json.get_string("Name", "")) {} + +JSON TeamIndex::Team::Member::json() const { + return JSON::dict({ + {"SerialNumber", this->serial_number}, + {"Flags", this->flags}, + {"Points", this->points}, + {"Name", this->name}, + }); +} + +uint32_t TeamIndex::Team::Member::privilege_level() const { + if (this->check_flag(Member::Flag::IS_MASTER)) { + return 0x40; + } else if (this->check_flag(Member::Flag::IS_LEADER)) { + return 0x30; + } else { + return 0x00; + } +} + +TeamIndex::Team::Team(uint32_t team_id) : Team() { + this->team_id = team_id; +} + +string TeamIndex::Team::json_filename() const { + return string_printf("system/teams/%08" PRIX32 ".json", this->team_id); +} + +string TeamIndex::Team::flag_filename() const { + return string_printf("system/teams/%08" PRIX32 ".bmp", this->team_id); +} + +void TeamIndex::Team::load_config() { + auto json = JSON::parse(load_file(this->json_filename())); + this->name = json.get_string("Name"); + for (const auto& member_it : json.get_list("Members")) { + Member m(*member_it); + uint32_t serial_number = m.serial_number; + this->members.emplace(serial_number, std::move(m)); + } + this->reward_flags = json.get_int("RewardFlags"); +} + +void TeamIndex::Team::save_config() const { + JSON members_json = JSON::list(); + for (const auto& it : this->members) { + members_json.emplace_back(it.second.json()); + } + JSON root = JSON::dict({ + {"Name", this->name}, + {"Members", std::move(members_json)}, + {"RewardFlags", this->reward_flags}, + }); + save_file(this->json_filename(), root.serialize(JSON::SerializeOption::FORMAT | JSON::SerializeOption::HEX_INTEGERS)); +} + +void TeamIndex::Team::load_flag() { + Image img(this->flag_filename()); + if (img.get_width() != 32 || img.get_height() != 32) { + throw runtime_error("incorrect flag image dimensions"); + } + this->flag_data.reset(new parray()); + for (size_t y = 0; y < 32; y++) { + for (size_t x = 0; x < 32; x++) { + this->flag_data->at(y * 0x20 + x) = encode_rgbx8888_to_xrgb1555(img.read_pixel(x, y)); + } + } +} + +void TeamIndex::Team::save_flag() const { + if (!this->flag_data) { + return; + } + Image img(32, 32, false); + for (size_t y = 0; y < 32; y++) { + for (size_t x = 0; x < 32; x++) { + img.write_pixel(x, y, decode_xrgb1555_to_rgba8888(this->flag_data->at(y * 0x20 + x))); + } + } + img.save(this->flag_filename(), Image::Format::WINDOWS_BITMAP); +} + +void TeamIndex::Team::delete_files() const { + string json_filename = this->json_filename(); + string flag_filename = this->flag_filename(); + remove(json_filename.c_str()); + remove(flag_filename.c_str()); +} + +PSOBBTeamMembership TeamIndex::Team::membership_for_member(uint32_t serial_number) const { + const auto& m = this->members.at(serial_number); + + PSOBBTeamMembership ret; + ret.guild_card_number = serial_number; + ret.team_id = this->team_id; + ret.unknown_a4 = 0; + ret.privilege_level = m.privilege_level(); + ret.unknown_a6 = 0; + ret.unknown_a7 = 0; + ret.unknown_a8 = 0; + ret.unknown_a9 = 0; + ret.team_name.encode("\tE" + this->name); + if (this->flag_data) { + ret.flag_data = *this->flag_data; + } else { + ret.flag_data.clear(); + } + ret.reward_flags = this->reward_flags; + return ret; +} + +size_t TeamIndex::Team::num_members() const { + return this->members.size(); +} + +size_t TeamIndex::Team::num_leaders() const { + size_t count = 0; + for (const auto& it : this->members) { + if (it.second.check_flag(Member::Flag::IS_LEADER)) { + count++; + } + } + return count; +} + +size_t TeamIndex::Team::max_members() const { + if (this->check_reward_flag(RewardFlag::MEMBERS_100_LEADERS_10)) { + return 100; + } else if (this->check_reward_flag(RewardFlag::MEMBERS_70_LEADERS_8)) { + return 70; + } else if (this->check_reward_flag(RewardFlag::MEMBERS_40_LEADERS_5)) { + return 40; + } else if (this->check_reward_flag(RewardFlag::MEMBERS_20_LEADERS_3)) { + return 20; + } else { + return 10; + } +} + +size_t TeamIndex::Team::max_leaders() const { + if (this->check_reward_flag(RewardFlag::MEMBERS_100_LEADERS_10)) { + return 10; + } else if (this->check_reward_flag(RewardFlag::MEMBERS_70_LEADERS_8)) { + return 8; + } else if (this->check_reward_flag(RewardFlag::MEMBERS_40_LEADERS_5)) { + return 5; + } else if (this->check_reward_flag(RewardFlag::MEMBERS_20_LEADERS_3)) { + return 3; + } else { + return 2; + } +} + +bool TeamIndex::Team::can_add_member() const { + return this->num_members() < this->max_members(); +} + +bool TeamIndex::Team::can_promote_leader() const { + return this->num_leaders() < this->max_leaders(); +} + +TeamIndex::TeamIndex(const string& directory) + : directory(directory), + next_team_id(1) { + if (!isdir(this->directory)) { + mkdir(this->directory.c_str(), 0755); + return; + } + for (const auto& filename : list_directory(this->directory)) { + string file_path = this->directory + "/" + filename; + if (filename == "base.json") { + auto json = JSON::parse(load_file(file_path)); + this->next_team_id = json.get_int("NextTeamID"); + } + if (ends_with(filename, ".json")) { + try { + uint32_t team_id = stoul(filename.substr(0, filename.size() - 5), nullptr, 16); + shared_ptr team(new Team(team_id)); + team->load_config(); + try { + team->load_flag(); + } catch (const exception& e) { + static_game_data_log.warning("Failed to load flag for team %08" PRIX32 ": %s", team_id, e.what()); + } + this->add_to_indexes(team); + static_game_data_log.info("Indexed team %08" PRIX32 " (%s) (%zu members)", team_id, team->name.c_str(), team->num_members()); + } catch (const exception& e) { + static_game_data_log.warning("Failed to index team from %s: %s", filename.c_str(), e.what()); + } + } + } +} + +size_t TeamIndex::count() const { + return this->id_to_team.size(); +} + +shared_ptr TeamIndex::get_by_id(uint32_t team_id) { + try { + return this->id_to_team.at(team_id); + } catch (const out_of_range&) { + return nullptr; + } +} + +shared_ptr TeamIndex::get_by_name(const string& name) { + try { + return this->name_to_team.at(name); + } catch (const out_of_range&) { + return nullptr; + } +} + +shared_ptr TeamIndex::get_by_serial_number(uint32_t serial_number) { + try { + return this->serial_number_to_team.at(serial_number); + } catch (const out_of_range&) { + return nullptr; + } +} + +vector> TeamIndex::all() { + vector> ret; + for (const auto& it : this->id_to_team) { + ret.emplace_back(it.second); + } + return ret; +} + +shared_ptr TeamIndex::create(string& name, uint32_t master_serial_number, const string& master_name) { + shared_ptr team(new Team(this->next_team_id++)); + save_file(this->directory + "/base.json", JSON::dict({{"NextTeamID", this->next_team_id}}).serialize()); + + Team::Member m; + m.serial_number = master_serial_number; + m.flags = 0; + m.points = 0; + m.name = master_name; + m.set_flag(Team::Member::Flag::IS_MASTER); + team->members.emplace(master_serial_number, std::move(m)); + team->name = name; + + team->save_config(); + this->add_to_indexes(team); + return team; +} + +void TeamIndex::disband(uint32_t team_id) { + auto team = this->id_to_team.at(team_id); + this->remove_from_indexes(team); + team->delete_files(); +} + +void TeamIndex::add_member(uint32_t team_id, uint32_t serial_number, const string& name) { + auto team = this->id_to_team.at(team_id); + if (!this->serial_number_to_team.emplace(serial_number, team).second) { + throw runtime_error("user is already in a different team"); + } + + Team::Member m; + m.serial_number = serial_number; + m.flags = 0; + m.points = 0; + m.name = name; + team->members.emplace(serial_number, std::move(m)); + + team->save_config(); +} + +void TeamIndex::remove_member(uint32_t serial_number) { + auto team_it = this->serial_number_to_team.find(serial_number); + if (team_it == this->serial_number_to_team.end()) { + throw runtime_error("client is not in any team"); + } + auto team = std::move(team_it->second); + this->serial_number_to_team.erase(team_it); + team->members.erase(serial_number); + if (team->members.empty()) { + this->disband(team->team_id); + } else { + team->save_config(); + } +} + +void TeamIndex::add_to_indexes(shared_ptr team) { + if (!this->id_to_team.emplace(team->team_id, team).second) { + throw runtime_error("team ID is already in use"); + } + if (!this->name_to_team.emplace(team->name, team).second) { + this->id_to_team.erase(team->team_id); + throw runtime_error("team name is already in use"); + } + for (const auto& it : team->members) { + if (!this->serial_number_to_team.emplace(it.second.serial_number, team).second) { + static_game_data_log.warning("Serial number %08" PRIX32 " (%010" PRIu32 ") exists in multiple teams", + it.second.serial_number, it.second.serial_number); + } + } +} + +void TeamIndex::remove_from_indexes(shared_ptr team) { + this->id_to_team.erase(team->team_id); + this->name_to_team.erase(team->name); + for (const auto& it : team->members) { + this->serial_number_to_team.erase(it.second.serial_number); + } +} diff --git a/src/TeamIndex.hh b/src/TeamIndex.hh new file mode 100644 index 00000000..06b9547f --- /dev/null +++ b/src/TeamIndex.hh @@ -0,0 +1,121 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "ItemNameIndex.hh" +#include "SaveFileFormats.hh" +#include "StaticGameData.hh" +#include "Text.hh" +#include "Version.hh" + +class TeamIndex { +public: + struct Team { + struct Member { + enum class Flag { + IS_MASTER = 0x01, + IS_LEADER = 0x02, + }; + uint32_t serial_number = 0; + uint8_t flags = 0; + uint64_t points = 0; + std::string name; + + Member() = default; + explicit Member(const JSON& json); + JSON json() const; + + [[nodiscard]] inline bool check_flag(Flag flag) const { + return !!(static_cast(flag) & this->flags); + } + inline void set_flag(Flag flag) { + this->flags |= static_cast(flag); + } + inline void clear_flag(Flag flag) { + this->flags &= (~static_cast(flag)); + } + + uint32_t privilege_level() const; + }; + + enum class RewardFlag { + // Only 0x00000001 and 0x00000002 are used by the client; the rest are + // free to be used however the server chooses. + TEAM_FLAG = 0x00000001, + DRESSING_ROOM = 0x00000002, + MEMBERS_20_LEADERS_3 = 0x00000004, + MEMBERS_40_LEADERS_5 = 0x00000008, + MEMBERS_70_LEADERS_8 = 0x00000010, + MEMBERS_100_LEADERS_10 = 0x00000020, + }; + + uint32_t team_id = 0; + std::string name; + std::unordered_map members; + uint32_t reward_flags = 0; + std::shared_ptr> flag_data; + + Team() = default; + explicit Team(uint32_t team_id); + JSON json() const; + + std::string json_filename() const; + std::string flag_filename() const; + + void load_config(); + void save_config() const; + void load_flag(); + void save_flag() const; + void delete_files() const; + + PSOBBTeamMembership membership_for_member(uint32_t serial_number) const; + + [[nodiscard]] inline bool check_reward_flag(RewardFlag flag) const { + return !!(static_cast(flag) & this->reward_flags); + } + inline void set_reward_flag(RewardFlag flag) { + this->reward_flags |= static_cast(flag); + } + inline void clear_reward_flag(RewardFlag flag) { + this->reward_flags &= (~static_cast(flag)); + } + + size_t num_members() const; + size_t num_leaders() const; + size_t max_members() const; + size_t max_leaders() const; + bool can_add_member() const; + bool can_promote_leader() const; + }; + + explicit TeamIndex(const std::string& directory); + ~TeamIndex() = default; + + size_t count() const; + std::shared_ptr get_by_id(uint32_t team_id); + std::shared_ptr get_by_name(const std::string& name); + std::shared_ptr get_by_serial_number(uint32_t serial_number); + std::vector> all(); + + std::shared_ptr create(std::string& name, uint32_t master_serial_number, const std::string& master_name); + void disband(uint32_t team_id); + + void add_member(uint32_t team_id, uint32_t serial_number, const std::string& name); + void remove_member(uint32_t serial_number); + +protected: + std::string directory; + uint32_t next_team_id; + std::unordered_map> id_to_team; + std::unordered_map> name_to_team; + std::unordered_map> serial_number_to_team; + + void add_to_indexes(std::shared_ptr team); + void remove_from_indexes(std::shared_ptr team); +};