From 9ea84d7101595ba273889fcd71a4bedd379641e7 Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Wed, 29 Nov 2023 11:35:15 -0800 Subject: [PATCH] implement most remaining BB team functions --- src/Client.cc | 7 +- src/Client.hh | 3 +- src/CommandFormats.hh | 56 ++++++---- src/ReceiveCommands.cc | 138 ++++++++---------------- src/ReceiveSubcommands.cc | 11 +- src/SendCommands.cc | 210 ++++++++++++++++--------------------- src/SendCommands.hh | 5 +- src/ServerShell.cc | 1 + src/ServerState.cc | 8 +- src/ServerState.hh | 1 + src/TeamIndex.cc | 141 +++++++++++++++++++++++-- src/TeamIndex.hh | 46 ++++++-- system/config.example.json | 97 +++++++++++++++++ tests/config.json | 96 +++++++++++++++++ 14 files changed, 557 insertions(+), 263 deletions(-) diff --git a/src/Client.cc b/src/Client.cc index 51b7768d..539f2822 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -245,7 +245,7 @@ shared_ptr Client::require_lobby() const { return l; } -shared_ptr Client::team() { +shared_ptr Client::team() { if (!this->license) { throw logic_error("Client::team called on client with no license"); } @@ -258,6 +258,7 @@ shared_ptr Client::team() { auto s = this->require_server_state(); auto team = s->team_index->get_by_id(this->license->bb_team_id); if (!team) { + this->log.info("License contains a team ID, but the team does not exist; clearing team ID from license"); this->license->bb_team_id = 0; this->license->save(); return nullptr; @@ -265,6 +266,7 @@ shared_ptr Client::team() { auto member_it = team->members.find(this->license->serial_number); if (member_it == team->members.end()) { + this->log.info("License contains a team ID, but the team does not contain this member; clearing team ID from license"); this->license->bb_team_id = 0; this->license->save(); return nullptr; @@ -277,8 +279,7 @@ shared_ptr Client::team() { 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(); + s->team_index->update_member_name(this->license->serial_number, name); } } diff --git a/src/Client.hh b/src/Client.hh index f5c7a953..15940553 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -54,7 +54,6 @@ 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, @@ -232,7 +231,7 @@ 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(); + std::shared_ptr team(); static void dispatch_save_game_data(evutil_socket_t, short, void* ctx); void save_game_data(); diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index a917b1cf..4e400e03 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -3254,9 +3254,8 @@ struct C_AddOrRemoveTeamMember_BB_03EA_05EA { struct SC_TeamChat_BB_07EA { pstring sender_name; - // It seems there are no real limits on the message length, other than the - // overall command length limit of 0x7C00 bytes. - // Text follows here + // Text follows here. The message is truncated by the client if it is longer + // than 0x8F wchar_ts. } __packed__; // 08EA (C->S): Get team member list @@ -3267,8 +3266,8 @@ struct SC_TeamChat_BB_07EA { struct S_TeamMemberList_BB_09EA { le_uint32_t entry_count = 0; struct Entry { - // This is displayed as "<%04d> %s" % (value, message) - le_uint32_t index = 0; + // This is displayed as "<%04d> %s" % (rank, name) + le_uint32_t rank = 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; @@ -3278,16 +3277,17 @@ struct S_TeamMemberList_BB_09EA { } __packed__; // 0CEA (S->C): Unknown +// The client appears to ignore this command. struct S_Unknown_BB_0CEA { parray unknown_a1; // Text follows here } __packed__; -// 0DEA (C->S): Unknown +// 0DEA (C->S): Get team name // No arguments -// 0EEA (S->C): Unknown +// 0EEA (S->C): Team name struct S_Unknown_BB_0EEA { parray unused; @@ -3306,8 +3306,10 @@ struct C_SetTeamFlag_BB_0FEA { // 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. +// server sends it, only header.flag is used. As with various other team +// commands, header.flag specifies the error code in this case. // header.flag specifies the new privilege level for the specified team member. +// Known values: 0 = normal, 0x30 = leader, 0x40 = master struct C_ChangeTeamMemberPrivilegeLevel_BB_11EA { le_uint32_t guild_card_number = 0; @@ -3333,6 +3335,8 @@ struct S_TeamMembershipInformation_BB_12EA { // header.flag specifies the number of entries. struct S_TeamInfoForPlayer_BB_13EA_15EA_Entry { + // The client uses the first four of these to determine if the player is in a + // team or not - if they are all zero, the player is not in a team. le_uint32_t guild_card_number = 0; le_uint32_t team_id = 0; le_uint32_t unknown_a3 = 0; @@ -3345,20 +3349,20 @@ struct S_TeamInfoForPlayer_BB_13EA_15EA_Entry { parray flag_data; } __packed__; -// 14EA (C->S): Unknown +// 14EA (C->S): Get team info for lobby players // No arguments. Client always sends 1 in the header.flag field. -// 15EA (S->C): Unknown +// 15EA (S->C): Team info for lobby players // header.flag specifies the number of entries. The entry format appears to be // the same as for the 13EA command. // 16EA (S->C): Unknown // No arguments except header.flag. -// 18EA: Team ranking information +// 18EA: Intra-team ranking information // No arguments (C->S) -struct S_TeamRankingInformation_BB_18EA { +struct S_IntraTeamRanking_BB_18EA { /* 0000 */ le_uint32_t ranking_points = 0; /* 0004 */ le_uint32_t unknown_a2 = 0; /* 0008 */ le_uint32_t points_remaining = 0; @@ -3378,13 +3382,7 @@ struct S_TeamRankingInformation_BB_18EA { // 19EA: Team reward list // No arguments (C->S) -struct S_TeamRewardList_BB_19EA { - le_uint32_t num_rewards_unlocked = 0; -} __packed__; - -// 1AEA: Team rewards available for purchase - -struct S_TeamRewardsAvailableForPurchase_BB_1AEA { +struct S_TeamRewardList_BB_19EA_1AEA { le_uint32_t num_entries; struct Entry { /* 0000 */ pstring name; @@ -3397,13 +3395,28 @@ struct S_TeamRewardsAvailableForPurchase_BB_1AEA { // Entry entries[num_entries]; } __packed__; +// 1AEA: Team rewards available for purchase +// Same format as 19EA. + // 1BEA (C->S): Buy team reward // No arguments except header.flag, which specifies a reward_id from a preceding // 1AEA command. -// 1CEA: Ranking information +// 1CEA: Cross-team ranking information // No arguments when sent by the client. +struct S_CrossTeamRanking_BB_1CEA { + le_uint32_t num_entries; + struct Entry { + /* 00 */ pstring team_name; + /* 20 */ le_uint32_t team_points = 0; + /* 24 */ le_uint32_t unknown_a1 = 0; + /* 28 */ + } __packed__; + // Variable length field: + // Entry entries[num_entries]; +} __packed__; + // 1DEA (S->C): Update team rewards bitmask // header.flag specifies the new rewards bitmask. @@ -3415,7 +3428,8 @@ struct C_Unknown_BB_1EEA { } __packed__; // 1FEA (S->C): Action result -// This command behaves exactly like 02EA. +// This command behaves exactly like 02EA. This command is presumably the +// response to whatever 1EEA does. // 20EA: Unknown // header.flag is used, but no other arguments diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index b5438d8b..b1941bbc 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -4528,23 +4528,27 @@ static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, stri try { added_c = s->find_client(nullptr, cmd.guild_card_number); } catch (const out_of_range&) { - send_lobby_message_box(c, "Player is offline"); + send_command(c, 0x04EA, 0x00000006); } 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"); + send_command(c, 0x04EA, 0x00000001); + send_command(added_c, 0x04EA, 0x00000001); + } 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); + s->team_index->add_member( + team->team_id, + added_c->license->serial_number, + added_c->game_data.character()->disp.name.decode(added_c->language())); send_update_team_metadata_for_client(added_c); send_team_membership_info(added_c); @@ -4623,8 +4627,7 @@ static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, stri 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(); + s->team_index->set_flag_data(team->team_id, cmd.flag_data); for (const auto& it : team->members) { try { auto member_c = s->find_client(nullptr, it.second.serial_number); @@ -4660,37 +4663,29 @@ static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, stri 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); + if (s->team_index->demote_leader(c->license->serial_number, cmd.guild_card_number)) { + send_command(c, 0x11EA, 0x00000000); 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); + if (s->team_index->promote_leader(c->license->serial_number, cmd.guild_card_number)) { + send_command(c, 0x11EA, 0x00000000); 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); + s->team_index->change_master(c->license->serial_number, cmd.guild_card_number); + send_command(c, 0x11EA, 0x00000000); send_updates_for_this_m = true; send_updates_for_other_m = true; break; @@ -4705,8 +4700,8 @@ static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, stri 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); + send_update_team_metadata_for_client(other_c); + send_team_membership_info(other_c); } catch (const out_of_range&) { } } @@ -4720,83 +4715,37 @@ static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, stri send_all_nearby_team_metadatas_to_client(c, false); break; case 0x18EA: // Ranking information - send_team_rank_info(c); + send_intra_team_ranking(c); break; - case 0x19EA: { - S_TeamRewardList_BB_19EA cmd = {0}; - send_command_t(c, 0x19EA, 0x00000000, cmd); - break; - } - case 0x1AEA: // Get team rewards available for purchase - send_team_rewards_available_for_purchase(c); + case 0x19EA: // List purchased team rewards + case 0x1AEA: // List team rewards available for purchase + send_team_reward_list(c, (command == 0x19EA)); break; case 0x1BEA: { // 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"); + const auto& reward = s->team_index->reward_definitions().at(flag); + + for (const auto& key : reward.prerequisite_keys) { + if (!team->has_reward(key)) { + throw runtime_error("not all prerequisite rewards have been purchased"); + } } - if (should_send_update_reward_flags) { + if (reward.is_unique && team->has_reward(reward.key)) { + throw runtime_error("team reward already purchased"); + } + + if (!reward.reward_item.empty()) { + // TODO: How do we do this? Do we just send a 6xBE in the lobby? + // (Once this is figured out, don't forget to move this block to after + // the reward is actually purchased) + throw runtime_error("team reward items are not implemented"); + } + + s->team_index->buy_reward(team->team_id, reward.key, reward.team_points, reward.reward_flag); + + if (reward.reward_flag != TeamIndex::Team::RewardFlag::NONE) { for (const auto& it : team->members) { try { auto member_c = s->find_client(nullptr, it.second.serial_number); @@ -4809,7 +4758,8 @@ static void on_EA_BB(shared_ptr c, uint16_t command, uint32_t flag, stri break; } case 0x1CEA: - throw runtime_error("team subcommand is not yet implemented"); + send_cross_team_ranking(c); + break; default: throw runtime_error("invalid team command"); } diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index 7e6dab97..c4df7ba2 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -2137,8 +2137,7 @@ void on_exchange_item_for_team_points_bb(shared_ptr c, uint8_t command, auto item = p->remove_item(cmd.item_id, cmd.amount, c->version() != Version::BB_V4); size_t points = s->item_parameter_table_v4->get_item_team_points(item); - team->members.at(c->license->serial_number).points += points; - team->save_config(); + s->team_index->add_member_points(c->license->serial_number, points); auto name = s->describe_item(c->version(), item, false); l->log.info("Player %hhu exchanged inventory item %hu:%08" PRIX32 " (%s) for %zu team points", @@ -2917,8 +2916,8 @@ SubcommandDefinition subcommand_definitions[0x100] = { /* 6xBE */ {0x00, 0x00, 0xBE, on_ep3_sound_chat}, /* 6xBF */ {0x00, 0x00, 0xBF, on_forward_check_size_ep3_lobby}, /* 6xC0 */ {0x00, 0x00, 0xC0, on_sell_item_at_shop_bb}, - /* 6xC1 */ {0x00, 0x00, 0xC1, nullptr}, - /* 6xC2 */ {0x00, 0x00, 0xC2, nullptr}, + /* 6xC1 */ {0x00, 0x00, 0xC1, on_forward_check_size}, + /* 6xC2 */ {0x00, 0x00, 0xC2, on_forward_check_size}, /* 6xC3 */ {0x00, 0x00, 0xC3, on_drop_partial_stack_bb}, /* 6xC4 */ {0x00, 0x00, 0xC4, on_sort_inventory_bb}, /* 6xC5 */ {0x00, 0x00, 0xC5, on_medical_center_bb}, @@ -2929,8 +2928,8 @@ SubcommandDefinition subcommand_definitions[0x100] = { /* 6xCA */ {0x00, 0x00, 0xCA, on_item_reward_request_bb}, /* 6xCB */ {0x00, 0x00, 0xCB, nullptr}, /* 6xCC */ {0x00, 0x00, 0xCC, on_exchange_item_for_team_points_bb}, - /* 6xCD */ {0x00, 0x00, 0xCD, nullptr}, - /* 6xCE */ {0x00, 0x00, 0xCE, nullptr}, + /* 6xCD */ {0x00, 0x00, 0xCD, on_forward_check_size}, + /* 6xCE */ {0x00, 0x00, 0xCE, on_forward_check_size}, /* 6xCF */ {0x00, 0x00, 0xCF, on_battle_restart_bb}, /* 6xD0 */ {0x00, 0x00, 0xD0, on_battle_level_up_bb}, /* 6xD1 */ {0x00, 0x00, 0xD1, on_request_challenge_grave_recovery_item_bb}, diff --git a/src/SendCommands.cc b/src/SendCommands.cc index 803be72b..7be5c73d 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -3313,10 +3313,10 @@ static S_TeamInfoForPlayer_BB_13EA_15EA_Entry team_metadata_for_client(shared_pt auto team = c->team(); S_TeamInfoForPlayer_BB_13EA_15EA_Entry cmd; cmd.lobby_client_id = c->lobby_client_id; - cmd.guild_card_number = c->license->serial_number; cmd.guild_card_number2 = c->license->serial_number; cmd.player_name = c->game_data.character()->disp.name; 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); @@ -3356,29 +3356,6 @@ void send_team_member_list(shared_ptr c) { 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"); - } - vector members; for (const auto& it : team->members) { members.emplace_back(&it.second); @@ -3388,10 +3365,45 @@ void send_team_rank_info(std::shared_ptr c) { }; sort(members.begin(), members.end(), rank_fn); - S_TeamRankingInformation_BB_18EA cmd; - cmd.points_remaining = 0; + S_TeamMemberList_BB_09EA header; + header.entry_count = members.size(); - vector entries; + vector entries; + entries.reserve(header.entry_count); + for (size_t z = 0; z < members.size(); z++) { + const auto* m = members[z]; + auto& e = entries.emplace_back(); + e.rank = z + 1; + 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_intra_team_ranking(std::shared_ptr c) { + auto team = c->team(); + if (!team) { + throw runtime_error("client is not in a team"); + } + + // TODO: At some point we should maintain a sorted index instead of sorting + // these on-demand. + vector members; + for (const auto& it : team->members) { + members.emplace_back(&it.second); + } + auto rank_fn = +[](const TeamIndex::Team::Member* a, const TeamIndex::Team::Member* b) { + return a->points > b->points; + }; + sort(members.begin(), members.end(), rank_fn); + + S_IntraTeamRanking_BB_18EA cmd; + cmd.points_remaining = team->points - team->spent_points; + cmd.num_entries = members.size(); + + vector entries; for (size_t z = 0; z < members.size(); z++) { const auto* m = members[z]; cmd.ranking_points += m->points; @@ -3402,111 +3414,69 @@ void send_team_rank_info(std::shared_ptr c) { e.player_name.encode(m->name); e.points = m->points; } - cmd.num_entries = entries.size(); send_command_t_vt(c, 0x18EA, 0x00000000, cmd, entries); } -void send_team_rewards_available_for_purchase(std::shared_ptr c) { +void send_cross_team_ranking(std::shared_ptr c) { + auto s = c->require_server_state(); + + // TODO: At some point we should maintain a sorted index instead of sorting + // these on-demand. + auto teams = s->team_index->all(); + auto rank_fn = +[](const shared_ptr& a, const shared_ptr& b) { + return a->points > b->points; + }; + sort(teams.begin(), teams.end(), rank_fn); + + size_t num_to_send = min(teams.size(), 0x300); + + S_CrossTeamRanking_BB_1CEA cmd; + cmd.num_entries = num_to_send; + + vector entries; + for (size_t z = 0; z < num_to_send; z++) { + auto t = teams[z]; + auto& e = entries.emplace_back(); + e.team_name.encode(t->name, c->language()); + e.team_points = t->points; + e.unknown_a1 = 0x01020304; + } + + send_command_t_vt(c, 0x1CEA, 0x00000000, cmd, entries); +} + +void send_team_reward_list(std::shared_ptr c, bool show_purchased) { auto team = c->team(); if (!team) { throw runtime_error("user is not in a team"); } + auto s = c->require_server_state(); - vector entries; - if (!team->check_reward_flag(TeamIndex::Team::RewardFlag::TEAM_FLAG)) { + vector entries; + for (const auto& reward : s->team_index->reward_definitions()) { + if (team->has_reward(reward.key) != show_purchased) { + continue; + } + bool has_all_prerequisites = true; + for (const auto& key : reward.prerequisite_keys) { + if (!team->has_reward(key)) { + has_all_prerequisites = false; + break; + } + } + if (!has_all_prerequisites) { + continue; + } 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; + e.name.encode(reward.name, c->language()); + e.description.encode(reward.description, c->language()); + e.reward_id = reward.menu_item_id; + e.team_points = reward.team_points; } - // 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; + S_TeamRewardList_BB_19EA_1AEA cmd; cmd.num_entries = entries.size(); - send_command_t_vt(c, 0x1AEA, 0x00000000, cmd, entries); + send_command_t_vt(c, show_purchased ? 0x19EA : 0x1AEA, 0x00000000, cmd, entries); } diff --git a/src/SendCommands.hh b/src/SendCommands.hh index 2f26d5c8..c629b855 100644 --- a/src/SendCommands.hh +++ b/src/SendCommands.hh @@ -395,5 +395,6 @@ void send_update_team_metadata_for_client(std::shared_ptr c); // 15EA (t 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 +void send_intra_team_ranking(std::shared_ptr c); // 18EA +void send_team_reward_list(std::shared_ptr c, bool show_purchased); // 19EA, 1AEA +void send_cross_team_ranking(std::shared_ptr c); // 1CEA diff --git a/src/ServerShell.cc b/src/ServerShell.cc index 5e6d3a53..5b791c0e 100644 --- a/src/ServerShell.cc +++ b/src/ServerShell.cc @@ -314,6 +314,7 @@ Proxy session commands:\n\ auto config_json = this->state->load_config(); this->state->parse_config(config_json, true); this->state->resolve_ep3_card_names(); + this->state->load_teams(); } else { throw invalid_argument("incorrect data type"); } diff --git a/src/ServerState.cc b/src/ServerState.cc index 28e5123d..6d217781 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -898,6 +898,11 @@ void ServerState::parse_config(const JSON& json, bool is_reload) { this->welcome_message = json.get_string("WelcomeMessage", ""); this->pc_patch_server_message = json.get_string("PCPatchServerMessage", ""); this->bb_patch_server_message = json.get_string("BBPatchServerMessage", ""); + + try { + this->team_reward_defs_json = std::move(json.at("TeamRewards")); + } catch (const out_of_range&) { + } } void ServerState::load_bb_private_keys() { @@ -919,7 +924,8 @@ void ServerState::load_licenses() { void ServerState::load_teams() { config_log.info("Indexing teams"); - this->team_index.reset(new TeamIndex("system/teams")); + this->team_index.reset(new TeamIndex("system/teams", this->team_reward_defs_json)); + this->team_reward_defs_json = nullptr; } void ServerState::load_patch_indexes() { diff --git a/src/ServerState.hh b/src/ServerState.hh index c3694aae..8e8a567b 100644 --- a/src/ServerState.hh +++ b/src/ServerState.hh @@ -143,6 +143,7 @@ struct ServerState : public std::enable_shared_from_this { std::shared_ptr license_index; std::shared_ptr team_index; + JSON team_reward_defs_json; std::shared_ptr information_menu_v2; std::shared_ptr information_menu_v3; diff --git a/src/TeamIndex.cc b/src/TeamIndex.cc index 5e3c88f3..fd84a3c6 100644 --- a/src/TeamIndex.cc +++ b/src/TeamIndex.cc @@ -52,11 +52,20 @@ string TeamIndex::Team::flag_filename() const { void TeamIndex::Team::load_config() { auto json = JSON::parse(load_file(this->json_filename())); this->name = json.get_string("Name"); + this->spent_points = json.get_int("SpentPoints"); + this->points = 0; for (const auto& member_it : json.get_list("Members")) { Member m(*member_it); + this->points += m.points; uint32_t serial_number = m.serial_number; this->members.emplace(serial_number, std::move(m)); } + try { + for (const auto& it : json.get_list("RewardKeys")) { + this->reward_keys.emplace(it->as_string()); + } + } catch (const out_of_range&) { + } this->reward_flags = json.get_int("RewardFlags"); } @@ -65,9 +74,15 @@ void TeamIndex::Team::save_config() const { for (const auto& it : this->members) { members_json.emplace_back(it.second.json()); } + JSON reward_keys_json = JSON::list(); + for (const auto& it : this->reward_keys) { + reward_keys_json.emplace_back(it); + } JSON root = JSON::dict({ {"Name", this->name}, + {"SpentPoints", this->spent_points}, {"Members", std::move(members_json)}, + {"RewardKeys", std::move(reward_keys_json)}, {"RewardFlags", this->reward_flags}, }); save_file(this->json_filename(), root.serialize(JSON::SerializeOption::FORMAT | JSON::SerializeOption::HEX_INTEGERS)); @@ -128,6 +143,10 @@ PSOBBTeamMembership TeamIndex::Team::membership_for_member(uint32_t serial_numbe return ret; } +bool TeamIndex::Team::has_reward(const string& key) const { + return this->reward_keys.count(key); +} + size_t TeamIndex::Team::num_members() const { return this->members.size(); } @@ -178,9 +197,37 @@ bool TeamIndex::Team::can_promote_leader() const { return this->num_leaders() < this->max_leaders(); } -TeamIndex::TeamIndex(const string& directory) +TeamIndex::Reward::Reward(uint32_t menu_item_id, const JSON& def_json) + : menu_item_id(menu_item_id), + key(def_json.get_string("Key")), + name(def_json.get_string("Name")), + description(def_json.get_string("Description")), + is_unique(def_json.get_bool("IsUnique", true)), + team_points(def_json.get_int("Points")) { + try { + for (const auto& it : def_json.get_list("PrerequisiteKeys")) { + this->prerequisite_keys.emplace(it->as_string()); + } + } catch (const out_of_range&) { + } + try { + this->reward_flag = static_cast(def_json.get_int("RewardFlag")); + } catch (const out_of_range&) { + } + try { + this->reward_item = ItemData::from_data(def_json.get_string("RewardItem")); + } catch (const out_of_range&) { + } +} + +TeamIndex::TeamIndex(const string& directory, const JSON& reward_defs_json) : directory(directory), next_team_id(1) { + uint32_t reward_menu_item_id = 0; + for (const auto& it : reward_defs_json.as_list()) { + this->reward_defs.emplace_back(reward_menu_item_id++, *it); + } + if (!isdir(this->directory)) { mkdir(this->directory.c_str(), 0755); return; @@ -214,7 +261,7 @@ size_t TeamIndex::count() const { return this->id_to_team.size(); } -shared_ptr TeamIndex::get_by_id(uint32_t team_id) { +shared_ptr TeamIndex::get_by_id(uint32_t team_id) const { try { return this->id_to_team.at(team_id); } catch (const out_of_range&) { @@ -222,7 +269,7 @@ shared_ptr TeamIndex::get_by_id(uint32_t team_id) { } } -shared_ptr TeamIndex::get_by_name(const string& name) { +shared_ptr TeamIndex::get_by_name(const string& name) const { try { return this->name_to_team.at(name); } catch (const out_of_range&) { @@ -230,7 +277,7 @@ shared_ptr TeamIndex::get_by_name(const string& name) { } } -shared_ptr TeamIndex::get_by_serial_number(uint32_t serial_number) { +shared_ptr TeamIndex::get_by_serial_number(uint32_t serial_number) const { try { return this->serial_number_to_team.at(serial_number); } catch (const out_of_range&) { @@ -238,15 +285,15 @@ shared_ptr TeamIndex::get_by_serial_number(uint32_t serial_numb } } -vector> TeamIndex::all() { - vector> ret; +vector> TeamIndex::all() const { + 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 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()); @@ -301,6 +348,86 @@ void TeamIndex::remove_member(uint32_t serial_number) { } } +void TeamIndex::update_member_name(uint32_t serial_number, const std::string& name) { + auto team = this->serial_number_to_team.at(serial_number); + auto& m = team->members.at(serial_number); + m.name = name; + team->save_config(); +} + +void TeamIndex::add_member_points(uint32_t serial_number, uint32_t points) { + auto team = this->serial_number_to_team.at(serial_number); + team->members.at(serial_number).points += points; + team->points += points; + team->save_config(); +} + +void TeamIndex::set_flag_data(uint32_t team_id, const parray& flag_data) { + auto team = this->id_to_team.at(team_id); + team->flag_data.reset(new parray(flag_data)); + team->save_flag(); +} + +bool TeamIndex::promote_leader(uint32_t master_serial_number, uint32_t leader_serial_number) { + auto team = this->serial_number_to_team.at(master_serial_number); + auto& master_m = team->members.at(master_serial_number); + if (!master_m.check_flag(TeamIndex::Team::Member::Flag::IS_MASTER)) { + throw runtime_error("incorrect master serial number"); + } + auto& other_m = team->members.at(leader_serial_number); + + if (other_m.check_flag(TeamIndex::Team::Member::Flag::IS_LEADER) || !team->can_promote_leader()) { + return false; + } + other_m.set_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + team->save_config(); + return true; +} + +bool TeamIndex::demote_leader(uint32_t master_serial_number, uint32_t leader_serial_number) { + auto team = this->serial_number_to_team.at(master_serial_number); + auto& master_m = team->members.at(master_serial_number); + if (!master_m.check_flag(TeamIndex::Team::Member::Flag::IS_MASTER)) { + throw runtime_error("incorrect master serial number"); + } + auto& other_m = team->members.at(leader_serial_number); + + if (!other_m.check_flag(TeamIndex::Team::Member::Flag::IS_LEADER)) { + return false; + } + other_m.clear_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + team->save_config(); + return true; +} + +void TeamIndex::change_master(uint32_t master_serial_number, uint32_t new_master_serial_number) { + auto team = this->serial_number_to_team.at(master_serial_number); + auto& master_m = team->members.at(master_serial_number); + if (!master_m.check_flag(TeamIndex::Team::Member::Flag::IS_MASTER)) { + throw runtime_error("incorrect master serial number"); + } + auto& new_master_m = team->members.at(new_master_serial_number); + + master_m.clear_flag(TeamIndex::Team::Member::Flag::IS_MASTER); + master_m.set_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + new_master_m.clear_flag(TeamIndex::Team::Member::Flag::IS_LEADER); + new_master_m.set_flag(TeamIndex::Team::Member::Flag::IS_MASTER); + team->save_config(); +} + +void TeamIndex::buy_reward(uint32_t team_id, const string& key, uint32_t points, Team::RewardFlag reward_flag) { + auto team = this->id_to_team.at(team_id); + if (team->spent_points + points > team->points) { + throw runtime_error("not enough points available"); + } + team->reward_keys.emplace(key); + team->spent_points += points; + if (reward_flag != Team::RewardFlag::NONE) { + team->set_reward_flag(reward_flag); + } + 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"); diff --git a/src/TeamIndex.hh b/src/TeamIndex.hh index 06b9547f..2b01be75 100644 --- a/src/TeamIndex.hh +++ b/src/TeamIndex.hh @@ -47,6 +47,7 @@ public: enum class RewardFlag { // Only 0x00000001 and 0x00000002 are used by the client; the rest are // free to be used however the server chooses. + NONE = 0x00000000, TEAM_FLAG = 0x00000001, DRESSING_ROOM = 0x00000002, MEMBERS_20_LEADERS_3 = 0x00000004, @@ -56,9 +57,12 @@ public: }; uint32_t team_id = 0; + uint32_t points = 0; + uint32_t spent_points = 0; std::string name; std::unordered_map members; uint32_t reward_flags = 0; + std::unordered_set reward_keys; std::shared_ptr> flag_data; Team() = default; @@ -86,6 +90,8 @@ public: this->reward_flags &= (~static_cast(flag)); } + [[nodiscard]] bool has_reward(const std::string& key) const; + size_t num_members() const; size_t num_leaders() const; size_t max_members() const; @@ -94,20 +100,45 @@ public: bool can_promote_leader() const; }; - explicit TeamIndex(const std::string& directory); + struct Reward { + uint32_t menu_item_id = 0; + std::string key; + std::string name; + std::string description; + std::unordered_set prerequisite_keys; + bool is_unique = true; + uint32_t team_points = 0; + Team::RewardFlag reward_flag = Team::RewardFlag::NONE; + ItemData reward_item; + + Reward(uint32_t menu_item_id, const JSON& def_json); + }; + + TeamIndex(const std::string& directory, const JSON& reward_defs_json); ~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(); + inline const std::vector& reward_definitions() const { + return this->reward_defs; + } - std::shared_ptr create(std::string& name, uint32_t master_serial_number, const std::string& master_name); + size_t count() const; + std::shared_ptr get_by_id(uint32_t team_id) const; + std::shared_ptr get_by_name(const std::string& name) const; + std::shared_ptr get_by_serial_number(uint32_t serial_number) const; + std::vector> all() const; + + 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); + void update_member_name(uint32_t serial_number, const std::string& name); + void add_member_points(uint32_t serial_number, uint32_t points); + void set_flag_data(uint32_t team_id, const parray& flag_data); + bool promote_leader(uint32_t master_serial_number, uint32_t leader_serial_number); + bool demote_leader(uint32_t master_serial_number, uint32_t leader_serial_number); + void change_master(uint32_t master_serial_number, uint32_t new_master_serial_number); + void buy_reward(uint32_t team_id, const std::string& key, uint32_t points, Team::RewardFlag reward_flag); protected: std::string directory; @@ -115,6 +146,7 @@ protected: std::unordered_map> id_to_team; std::unordered_map> name_to_team; std::unordered_map> serial_number_to_team; + std::vector reward_defs; void add_to_indexes(std::shared_ptr team); void remove_from_indexes(std::shared_ptr team); diff --git a/system/config.example.json b/system/config.example.json index 8ab49315..63e492a3 100644 --- a/system/config.example.json +++ b/system/config.example.json @@ -559,6 +559,103 @@ // limitation, and must be at least 1. "BBGlobalEXPMultiplier": 1, + // BB team reward definitions. + "TeamRewards": [ + { + "Key": "TeamFlag", + "Name": "Team flag", + "Description": "Show a custom banner\nabove your team's\nplayers in the lobby", + "Points": 2500, + "RewardFlag": 0x00000001, + }, { + "Key": "DressingRoom", + "Name": "Dressing room", + "Description": "Unlock the ability to\nchange your character's\nappearance", + "Points": 3000, + "RewardFlag": 0x00000002, + }, { + "Key": "Members20Leaders3", + "Name": "20 team members", + "Description": "Increase your team's\nsize limit to 30 members\nand 3 leaders", + "Points": 1500, + "RewardFlag": 0x00000004, + }, { + "Key": "Members40Leaders5", + "Name": "40 team members", + "Description": "Increase your team's\nsize limit to 40 members\nand 5 leaders", + "Points": 4000, + "PrerequisiteKeys": ["Members20Leaders3"], + "RewardFlag": 0x00000008, + }, { + "Key": "Members70Leaders8", + "Name": "70 team members", + "Description": "Increase your team's\nsize limit to 70 members\nand 8 leaders", + "Points": 9000, + "PrerequisiteKeys": ["Members40Leaders5"], + "RewardFlag": 0x00000010, + }, { + "Key": "Members100Leaders10", + "Name": "100 team members", + "Description": "Increase your team's\nsize limit to 100 members\nand 10 leaders", + "Points": 18000, + "PrerequisiteKeys": ["Members70Leaders8"], + "RewardFlag": 0x00000020, + }, { + "Key": "PointOfDisaster", + "Name": "Quest: Point of Disaster", + "Description": "Unlock the quest\nPoint of Disaster\nfor your team", + "Points": 1000, + }, { + "Key": "ToysTwilight", + "Name": "Quest: Toys Twilight", + "Description": "Unlock the quest\nToys Twilight\nfor your team", + "Points": 1000, + }, { + "Key": "CommanderBlade", + "Name": "Commander Blade", + "Description": "Create a Commander\nBlade weapon", + "IsUnique": false, + "Points": 8000, + "RewardItem": "00B200", + }, { + "Key": "UnionGuard", + "Name": "Union Guard", + "Description": "Create a Union Guard\nshield", + "IsUnique": false, + "Points": 100, + // TODO: There are 4 of these in names-v4.json; which should we use? + "RewardItem": "010295", + }, { + "Key": "Ticket500", + "Name": "Team Points Ticket 500", + "Description": "Create a 500-point ticket", + "IsUnique": false, + "Points": 500, + "RewardItem": "031900", + }, { + "Key": "Ticket1000", + "Name": "Team Points Ticket 1000", + "Description": "Create a 1000-point ticket", + "IsUnique": false, + "Points": 1000, + "RewardItem": "031901", + }, { + "Key": "Ticket5000", + "Name": "Team Points Ticket 5000", + "Description": "Create a 5000-point ticket", + "IsUnique": false, + "Points": 5000, + "RewardItem": "031902", + }, { + "Key": "Ticket10000", + "Name": "Team Points Ticket 10000", + "Description": "Create a 10000-point ticket", + "IsUnique": false, + "Points": 10000, + "RewardItem": "031903", + }, + ], + // Cheat mode behavior. There are three values: // "Off": Cheat mode is disabled on the entire server. Cheat mode cannot be // enabled in games, and the $cheat command does nothing. This also diff --git a/tests/config.json b/tests/config.json index 84a607e0..bb73dccb 100644 --- a/tests/config.json +++ b/tests/config.json @@ -184,4 +184,100 @@ "031000", ], "BBGlobalEXPMultiplier": 1, + + "TeamRewards": [ + { + "Key": "TeamFlag", + "Name": "Team flag", + "Description": "Show a custom banner\nabove your team's\nplayers in the lobby", + "Points": 2500, + "RewardFlag": 0x00000001, + }, { + "Key": "DressingRoom", + "Name": "Dressing room", + "Description": "Unlock the ability to\nchange your character's\nappearance", + "Points": 3000, + "RewardFlag": 0x00000002, + }, { + "Key": "Members20Leaders3", + "Name": "20 team members", + "Description": "Increase your team's\nsize limit to 30 members\nand 3 leaders", + "Points": 1500, + "RewardFlag": 0x00000004, + }, { + "Key": "Members40Leaders5", + "Name": "40 team members", + "Description": "Increase your team's\nsize limit to 40 members\nand 5 leaders", + "Points": 4000, + "PrerequisiteKeys": ["Members20Leaders3"], + "RewardFlag": 0x00000008, + }, { + "Key": "Members70Leaders8", + "Name": "70 team members", + "Description": "Increase your team's\nsize limit to 70 members\nand 8 leaders", + "Points": 9000, + "PrerequisiteKeys": ["Members40Leaders5"], + "RewardFlag": 0x00000010, + }, { + "Key": "Members100Leaders10", + "Name": "100 team members", + "Description": "Increase your team's\nsize limit to 100 members\nand 10 leaders", + "Points": 18000, + "PrerequisiteKeys": ["Members70Leaders8"], + "RewardFlag": 0x00000020, + }, { + "Key": "PointOfDisaster", + "Name": "Quest: Point of Disaster", + "Description": "Unlock the quest\nPoint of Disaster\nfor your team", + "Points": 1000, + }, { + "Key": "ToysTwilight", + "Name": "Quest: Toys Twilight", + "Description": "Unlock the quest\nToys Twilight\nfor your team", + "Points": 1000, + }, { + "Key": "CommanderBlade", + "Name": "Commander Blade", + "Description": "Create a Commander\nBlade weapon", + "IsUnique": false, + "Points": 8000, + "RewardItem": "00B200", + }, { + "Key": "UnionGuard", + "Name": "Union Guard", + "Description": "Create a Union Guard\nshield", + "IsUnique": false, + "Points": 100, + // TODO: There are 4 of these in names-v4.json; which should we use? + "RewardItem": "010295", + }, { + "Key": "Ticket500", + "Name": "Team Points Ticket 500", + "Description": "Create a 500-point ticket", + "IsUnique": false, + "Points": 500, + "RewardItem": "031900", + }, { + "Key": "Ticket1000", + "Name": "Team Points Ticket 1000", + "Description": "Create a 1000-point ticket", + "IsUnique": false, + "Points": 1000, + "RewardItem": "031901", + }, { + "Key": "Ticket5000", + "Name": "Team Points Ticket 5000", + "Description": "Create a 5000-point ticket", + "IsUnique": false, + "Points": 5000, + "RewardItem": "031902", + }, { + "Key": "Ticket10000", + "Name": "Team Points Ticket 10000", + "Description": "Create a 10000-point ticket", + "IsUnique": false, + "Points": 10000, + "RewardItem": "031903", + }, + ], }