Add TeamSync canonical team state apply scaffold

This commit is contained in:
2026-06-12 00:57:17 -04:00
parent 6995e5b7f4
commit 4d893607c2
5 changed files with 141 additions and 0 deletions
+12
View File
@@ -1648,6 +1648,18 @@ void ServerState::load_accounts() {
void ServerState::load_teams() {
config_log.info_f("Indexing teams");
this->team_index = std::make_shared<TeamIndex>("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() {
+96
View File
@@ -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<uint32_t, std::shared_ptr<Team>> new_id_to_team;
std::unordered_map<std::string, std::shared_ptr<Team>> new_name_to_team;
std::unordered_map<uint32_t, std::shared_ptr<Team>> 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>(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> team) {
if (!this->id_to_team.emplace(team->team_id, team).second) {
throw std::runtime_error("team ID is already in use");
+4
View File
@@ -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;
+25
View File
@@ -10,6 +10,7 @@
#include <cstdint>
#include <cstdio>
#include <format>
#include <functional>
#include <memory>
#include <mutex>
#include <random>
@@ -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<std::mutex> 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<std::mutex> 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<void> 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<std::mutex> g(exchange_state_mutex);
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <asio.hpp>
@@ -32,6 +33,9 @@ void configure_from_json(const phosg::JSON& json);
bool enabled();
bool relay_team_chat_enabled();
using CanonicalTeamStateCallback = std::function<void(const phosg::JSON&)>;
void set_canonical_team_state_callback(CanonicalTeamStateCallback cb);
void start_exchange_task(asio::io_context& io_context);
} // namespace TeamSync