diff --git a/src/ServerState.cc b/src/ServerState.cc index 48924f56..b8dedf6d 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -1648,6 +1648,18 @@ void ServerState::load_accounts() { void ServerState::load_teams() { config_log.info_f("Indexing teams"); this->team_index = std::make_shared("system/teams", this->team_reward_defs_json); + + TeamSync::set_canonical_team_state_callback([this](const phosg::JSON& canonical_team_state) -> void { + if (!this->team_index) { + return; + } + try { + this->team_index->replace_all_from_authority(canonical_team_state); + config_log.info_f("Applied canonical TeamSync team state"); + } catch (const std::exception& e) { + config_log.warning_f("Failed to apply canonical TeamSync team state: {}", e.what()); + } + }); } void ServerState::load_patch_indexes() { diff --git a/src/TeamIndex.cc b/src/TeamIndex.cc index 4b7e11b7..d78654be 100644 --- a/src/TeamIndex.cc +++ b/src/TeamIndex.cc @@ -454,6 +454,102 @@ void TeamIndex::buy_reward(uint32_t team_id, const std::string& key, uint32_t po team->save_config(); } +void TeamIndex::replace_all_from_authority(const phosg::JSON& canonical_team_state) { + uint32_t new_next_team_id = canonical_team_state.get_int("next_team_id", 1); + if (new_next_team_id < 1) { + new_next_team_id = 1; + } + + std::unordered_map> new_id_to_team; + std::unordered_map> new_name_to_team; + std::unordered_map> new_account_id_to_team; + + const auto& teams_json = canonical_team_state.get("teams", phosg::JSON::list()).as_list(); + for (const auto& team_json_p : teams_json) { + const auto& team_json = *team_json_p; + uint32_t team_id = team_json.get_int("team_id"); + if (team_id == 0) { + throw std::runtime_error("authority team has invalid team_id"); + } + + auto team = std::make_shared(team_id); + team->name = team_json.get_string("name", ""); + if (team->name.empty()) { + throw std::runtime_error("authority team has empty name"); + } + + team->reward_flags = team_json.get_int("reward_flags", 0); + team->spent_points = team_json.get_int("spent_points", 0); + team->points = 0; + + team->reward_keys.clear(); + for (const auto& key_json_p : team_json.get("reward_keys", phosg::JSON::list()).as_list()) { + team->reward_keys.emplace(key_json_p->as_string()); + } + + const auto& members_json = team_json.get("members", phosg::JSON::list()).as_list(); + for (const auto& member_json_p : members_json) { + const auto& member_json = *member_json_p; + Team::Member m; + m.account_id = member_json.get_int("account_id", member_json.get_int("AccountID", 0)); + m.flags = member_json.get_int("flags", member_json.get_int("Flags", 0)); + m.points = member_json.get_int("points", member_json.get_int("Points", 0)); + m.name = member_json.get_string("name", member_json.get_string("Name", "")); + + if (m.account_id == 0) { + throw std::runtime_error("authority team member has invalid account_id"); + } + if (m.check_flag(Team::Member::Flag::IS_MASTER)) { + team->master_account_id = m.account_id; + } + team->points += m.points; + team->members.emplace(m.account_id, std::move(m)); + } + + if (team->members.empty()) { + throw std::runtime_error("authority team has no members"); + } + + if (!new_id_to_team.emplace(team->team_id, team).second) { + throw std::runtime_error("authority state has duplicate team_id"); + } + if (!new_name_to_team.emplace(team->name, team).second) { + throw std::runtime_error("authority state has duplicate team name"); + } + for (const auto& [account_id, member] : team->members) { + if (!new_account_id_to_team.emplace(account_id, team).second) { + throw std::runtime_error("authority state has account in multiple teams"); + } + } + } + + std::filesystem::create_directories(this->directory.c_str()); + + for (const auto& item : std::filesystem::directory_iterator(this->directory)) { + const std::string filename = item.path().filename().string(); + if ((filename != "base.json") && (filename.ends_with(".json") || filename.ends_with(".bmp"))) { + std::filesystem::remove(item.path()); + } + } + + phosg::save_file( + this->directory + "/base.json", + phosg::JSON::dict({{"NextTeamID", new_next_team_id}}).serialize( + phosg::JSON::SerializeOption::FORMAT | + phosg::JSON::SerializeOption::HEX_INTEGERS | + phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY)); + + this->next_team_id = new_next_team_id; + this->id_to_team.swap(new_id_to_team); + this->name_to_team.swap(new_name_to_team); + this->account_id_to_team.swap(new_account_id_to_team); + + for (const auto& [team_id, team] : this->id_to_team) { + team->save_config(); + } +} + + void TeamIndex::add_to_indexes(std::shared_ptr team) { if (!this->id_to_team.emplace(team->team_id, team).second) { throw std::runtime_error("team ID is already in use"); diff --git a/src/TeamIndex.hh b/src/TeamIndex.hh index eaab5c0c..a78b1233 100644 --- a/src/TeamIndex.hh +++ b/src/TeamIndex.hh @@ -144,6 +144,10 @@ public: void change_master(uint32_t master_account_id, uint32_t new_master_account_id); void buy_reward(uint32_t team_id, const std::string& key, uint32_t points, Team::RewardFlag reward_flag); + // Replaces all local BB team state with coordinator-authoritative state. + // This updates disk and in-memory indexes immediately. + void replace_all_from_authority(const phosg::JSON& canonical_team_state); + protected: std::string directory; uint32_t next_team_id; diff --git a/src/TeamSync.cc b/src/TeamSync.cc index c97409db..e57dce0d 100644 --- a/src/TeamSync.cc +++ b/src/TeamSync.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,9 @@ struct ExchangeState { static std::mutex exchange_state_mutex; static ExchangeState exchange_state; +static std::mutex canonical_team_state_callback_mutex; +static CanonicalTeamStateCallback canonical_team_state_callback; + static std::string source_label(const Config& cfg) { if (!cfg.source.empty()) { return cfg.source; @@ -431,6 +435,22 @@ bool relay_team_chat_enabled() { return cfg.enabled && cfg.relay_team_chat; } +void set_canonical_team_state_callback(CanonicalTeamStateCallback cb) { + std::lock_guard g(canonical_team_state_callback_mutex); + canonical_team_state_callback = std::move(cb); +} + +static void apply_canonical_team_state(const phosg::JSON& canonical_team_state) { + CanonicalTeamStateCallback cb; + { + std::lock_guard g(canonical_team_state_callback_mutex); + cb = canonical_team_state_callback; + } + if (cb) { + cb(canonical_team_state); + } +} + // This helper only builds empty event batches from trusted config fields. // Do not extend the format-string JSON path for player-controlled data; use a // real JSON object/serializer when team chat events are added. @@ -467,6 +487,11 @@ static asio::awaitable run_empty_exchange_once(const Config& cfg) { bool truncated = response.get_bool("truncated", false); size_t inbound_events = response.get("events", phosg::JSON::list()).as_list().size(); + const auto& canonical_team_state = response.get("canonical_team_state", phosg::JSON::dict()); + if (!canonical_team_state.as_dict().empty()) { + apply_canonical_team_state(canonical_team_state); + } + { std::lock_guard g(exchange_state_mutex); diff --git a/src/TeamSync.hh b/src/TeamSync.hh index 6dbfca2d..bb2d257b 100644 --- a/src/TeamSync.hh +++ b/src/TeamSync.hh @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -32,6 +33,9 @@ void configure_from_json(const phosg::JSON& json); bool enabled(); bool relay_team_chat_enabled(); +using CanonicalTeamStateCallback = std::function; +void set_canonical_team_state_callback(CanonicalTeamStateCallback cb); + void start_exchange_task(asio::io_context& io_context); } // namespace TeamSync