account sync hooks

Feature/account sync hooks
This commit is contained in:
James Osborne
2026-06-08 12:48:08 -04:00
committed by GitHub
8 changed files with 418 additions and 0 deletions
+1
View File
@@ -50,6 +50,7 @@ add_custom_target(
set(SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/src/Revision.cc
src/Account.cc
src/AccountSync.cc
src/AddressTranslator.cc
src/AFSArchive.cc
src/AsyncHTTPServer.cc
+3
View File
@@ -9,6 +9,7 @@
#include <phosg/Time.hh>
#include "Account.hh"
#include "AccountSync.hh"
std::shared_ptr<DCNTELicense> DCNTELicense::from_json(const phosg::JSON& json) {
auto ret = std::make_shared<DCNTELicense>();
@@ -400,6 +401,8 @@ void Account::save() const {
phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::HEX_INTEGERS);
std::string filename = std::format("system/licenses/{:010}.json", this->account_id);
phosg::save_file(filename, json_data);
AccountSync::notify_account_saved(this->account_id, filename);
}
}
+298
View File
@@ -0,0 +1,298 @@
#include "AccountSync.hh"
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <format>
#include <fstream>
#include <mutex>
#include <stdexcept>
#include <string>
namespace AccountSync {
static std::mutex config_mutex;
static std::mutex spool_mutex;
static Config current_config;
static uint64_t now_usecs() {
using namespace std::chrono;
return duration_cast<microseconds>(system_clock::now().time_since_epoch()).count();
}
static Config get_config() {
std::lock_guard<std::mutex> g(config_mutex);
return current_config;
}
static std::string source_label(const Config& cfg) {
if (!cfg.source.empty()) {
return cfg.source;
}
if (!cfg.source_region.empty() && !cfg.source_ship.empty()) {
return cfg.source_region + "-" + cfg.source_ship;
}
if (!cfg.source_region.empty()) {
return cfg.source_region;
}
if (!cfg.source_ship.empty()) {
return cfg.source_ship;
}
return "unknown";
}
static std::string json_escape(const std::string& s) {
std::string ret;
ret.reserve(s.size() + 8);
for (unsigned char ch : s) {
switch (ch) {
case '\\':
ret += "\\\\";
break;
case '"':
ret += "\\\"";
break;
case '\b':
ret += "\\b";
break;
case '\f':
ret += "\\f";
break;
case '\n':
ret += "\\n";
break;
case '\r':
ret += "\\r";
break;
case '\t':
ret += "\\t";
break;
default:
if (ch < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04X", ch);
ret += buf;
} else {
ret += static_cast<char>(ch);
}
}
}
return ret;
}
static void append_spool_line(const Config& cfg, const std::string& line) {
if (cfg.spool_directory.empty()) {
return;
}
try {
std::lock_guard<std::mutex> g(spool_mutex);
std::filesystem::create_directories(cfg.spool_directory);
std::filesystem::path path = std::filesystem::path(cfg.spool_directory) / "events.jsonl";
std::ofstream f(path, std::ios::app);
if (!f) {
throw std::runtime_error("failed to open spool file");
}
f << line << '\n';
} catch (const std::exception& e) {
std::fprintf(stderr,
"[AccountSync] warning failed_to_write_spool directory=%s error=%s\n",
cfg.spool_directory.c_str(),
e.what());
}
}
static std::string base_event_json(const Config& cfg, const char* event, uint32_t account_id) {
return std::format(
"{{\"timestamp_usecs\":{},\"producer\":\"newserv\",\"source\":\"{}\",\"source_region\":\"{}\",\"source_ship\":\"{}\",\"account_store\":\"{}\",\"event\":\"{}\",\"account_id\":{},\"account_id_str\":\"{:010}\"",
now_usecs(),
json_escape(source_label(cfg)),
json_escape(cfg.source_region),
json_escape(cfg.source_ship),
json_escape(cfg.account_store),
json_escape(event),
static_cast<unsigned int>(account_id),
static_cast<unsigned int>(account_id));
}
void configure(const Config& cfg) {
{
std::lock_guard<std::mutex> g(config_mutex);
current_config = cfg;
}
if (cfg.enabled) {
std::fprintf(stderr,
"[AccountSync] config enabled source=%s source_region=%s source_ship=%s account_store=%s coordinator_url=%s notify_bb_sessions=%s spool_directory=%s login_locks=%s\n",
source_label(cfg).c_str(),
cfg.source_region.c_str(),
cfg.source_ship.c_str(),
cfg.account_store.c_str(),
cfg.coordinator_url.c_str(),
cfg.notify_bb_sessions ? "true" : "false",
cfg.spool_directory.c_str(),
cfg.enable_login_locks ? "true" : "false");
}
}
void configure_from_json(const phosg::JSON& json) {
Config cfg;
cfg.enabled = json.get_bool("Enabled", false);
const std::string legacy_region = json.get_string("Region", "");
cfg.source = json.get_string("Source", legacy_region);
cfg.source_region = json.get_string("SourceRegion", "");
cfg.source_ship = json.get_string("SourceShip", "");
cfg.account_store = json.get_string("AccountStore", "shared");
if (cfg.source_region.empty() && cfg.source_ship.empty() && !legacy_region.empty()) {
size_t dash_offset = legacy_region.find('-');
if (dash_offset != std::string::npos) {
cfg.source_region = legacy_region.substr(0, dash_offset);
cfg.source_ship = legacy_region.substr(dash_offset + 1);
} else {
cfg.source_region = legacy_region;
}
}
cfg.coordinator_url = json.get_string("CoordinatorURL", "");
cfg.shared_secret = json.get_string("SharedSecret", "");
cfg.request_timeout_usecs = json.get_int("RequestTimeoutUsecs", 3000000);
cfg.fail_open = json.get_bool("FailOpen", false);
cfg.notify_account_saves = json.get_bool("NotifyAccountSaves", true);
cfg.notify_player_saves = json.get_bool("NotifyPlayerSaves", true);
cfg.notify_backup_saves = json.get_bool("NotifyBackupSaves", true);
cfg.enable_login_locks = json.get_bool("EnableLoginLocks", false);
cfg.notify_bb_sessions = json.get_bool("NotifyBBSessions", cfg.enable_login_locks);
cfg.spool_directory = json.get_string("SpoolDirectory", "system/account-sync-spool");
configure(cfg);
}
void notify_account_saved(uint32_t account_id, const std::string& filename) {
auto cfg = get_config();
if (!cfg.enabled || !cfg.notify_account_saves) {
return;
}
std::fprintf(stderr,
"[AccountSync] event=account_saved source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u filename=%s\n",
source_label(cfg).c_str(),
cfg.source_region.c_str(),
cfg.source_ship.c_str(),
cfg.account_store.c_str(),
static_cast<unsigned int>(account_id),
filename.c_str());
auto line = base_event_json(cfg, "account_saved", account_id) +
std::format(",\"filename\":\"{}\"}}", json_escape(filename));
append_spool_line(cfg, line);
}
void notify_backup_saved(uint32_t account_id, size_t slot, const std::string& filename) {
auto cfg = get_config();
if (!cfg.enabled || !cfg.notify_backup_saves) {
return;
}
std::fprintf(stderr,
"[AccountSync] event=backup_saved source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u slot=%zu filename=%s\n",
source_label(cfg).c_str(),
cfg.source_region.c_str(),
cfg.source_ship.c_str(),
cfg.account_store.c_str(),
static_cast<unsigned int>(account_id),
slot,
filename.c_str());
auto line = base_event_json(cfg, "backup_saved", account_id) +
std::format(",\"slot\":{},\"filename\":\"{}\"}}", slot, json_escape(filename));
append_spool_line(cfg, line);
}
void notify_player_state_saved(
const char* reason,
uint32_t account_id,
const std::string& bb_username,
const std::string& filename) {
auto cfg = get_config();
if (!cfg.enabled || !cfg.notify_player_saves) {
return;
}
std::fprintf(stderr,
"[AccountSync] event=player_state_saved source=%s source_region=%s source_ship=%s account_store=%s reason=%s account_id=%010u bb_username=%s filename=%s\n",
source_label(cfg).c_str(),
cfg.source_region.c_str(),
cfg.source_ship.c_str(),
cfg.account_store.c_str(),
reason,
static_cast<unsigned int>(account_id),
bb_username.c_str(),
filename.c_str());
auto line = base_event_json(cfg, "player_state_saved", account_id) +
std::format(",\"reason\":\"{}\",\"bb_username\":\"{}\",\"filename\":\"{}\"}}",
json_escape(reason),
json_escape(bb_username),
json_escape(filename));
append_spool_line(cfg, line);
}
void notify_bb_login_start(
uint32_t account_id,
const std::string& bb_username,
int64_t character_slot,
uint8_t connection_phase) {
auto cfg = get_config();
if (!cfg.enabled || !cfg.notify_bb_sessions) {
return;
}
std::fprintf(stderr,
"[AccountSync] event=bb_login_start source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u bb_username=%s character_slot=%lld connection_phase=%u\n",
source_label(cfg).c_str(),
cfg.source_region.c_str(),
cfg.source_ship.c_str(),
cfg.account_store.c_str(),
static_cast<unsigned int>(account_id),
bb_username.c_str(),
static_cast<long long>(character_slot),
static_cast<unsigned int>(connection_phase));
auto line = base_event_json(cfg, "bb_login_start", account_id) +
std::format(",\"bb_username\":\"{}\",\"character_slot\":{},\"connection_phase\":{}}}",
json_escape(bb_username),
static_cast<long long>(character_slot),
static_cast<unsigned int>(connection_phase));
append_spool_line(cfg, line);
}
void notify_bb_login_end(
uint32_t account_id,
const std::string& bb_username,
int64_t character_slot) {
auto cfg = get_config();
if (!cfg.enabled || !cfg.notify_bb_sessions) {
return;
}
std::fprintf(stderr,
"[AccountSync] event=bb_login_end source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u bb_username=%s character_slot=%lld\n",
source_label(cfg).c_str(),
cfg.source_region.c_str(),
cfg.source_ship.c_str(),
cfg.account_store.c_str(),
static_cast<unsigned int>(account_id),
bb_username.c_str(),
static_cast<long long>(character_slot));
auto line = base_event_json(cfg, "bb_login_end", account_id) +
std::format(",\"bb_username\":\"{}\",\"character_slot\":{}}}",
json_escape(bb_username),
static_cast<long long>(character_slot));
append_spool_line(cfg, line);
}
} // namespace AccountSync
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <phosg/JSON.hh>
namespace AccountSync {
struct Config {
bool enabled = false;
// Source identity. Region is no longer enough because account JSON is shared
// across live/test/hardcore in a region.
std::string source;
std::string source_region;
std::string source_ship;
std::string account_store = "shared";
std::string coordinator_url;
std::string shared_secret;
uint64_t request_timeout_usecs = 3000000;
bool fail_open = false;
bool notify_account_saves = true;
bool notify_player_saves = true;
bool notify_backup_saves = true;
bool notify_bb_sessions = false;
bool enable_login_locks = false; // Reserved for future blocking lock behavior
std::string spool_directory = "system/account-sync-spool";
};
void configure(const Config& cfg);
void configure_from_json(const phosg::JSON& json);
void notify_account_saved(uint32_t account_id, const std::string& filename);
void notify_backup_saved(uint32_t account_id, size_t slot, const std::string& filename);
void notify_player_state_saved(
const char* reason,
uint32_t account_id,
const std::string& bb_username,
const std::string& filename);
void notify_bb_login_start(
uint32_t account_id,
const std::string& bb_username,
int64_t character_slot,
uint8_t connection_phase);
void notify_bb_login_end(
uint32_t account_id,
const std::string& bb_username,
int64_t character_slot);
} // namespace AccountSync
+13
View File
@@ -22,6 +22,7 @@
#include "Server.hh"
#include "StaticGameData.hh"
#include "Text.hh"
#include "AccountSync.hh"
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Tools
@@ -491,12 +492,22 @@ static asio::awaitable<void> server_command_bbchar_savechar(const Args& a, bool
? Client::character_filename(dest_bb_license->username, dest_character_index)
: Client::backup_character_filename(dest_account->account_id, dest_character_index, is_ep3(a.c->version()));
auto log_account_sync_backup_saved = [&]() -> void {
if (!is_bb_conversion) {
AccountSync::notify_backup_saved(
dest_account->account_id,
dest_character_index,
filename);
}
};
if (ch.is_full_info) {
// Client sent 30; ch contains the verbatim save file from the client
if (ch.ep3_character) {
try {
Client::save_ep3_character_file(filename, *ch.ep3_character);
send_text_message(a.c, "$C7Character data saved\n(full save file)");
log_account_sync_backup_saved();
} catch (const std::exception& e) {
send_text_message_fmt(a.c, "$C6Character data could\nnot be saved:\n{}", e.what());
}
@@ -505,6 +516,7 @@ static asio::awaitable<void> server_command_bbchar_savechar(const Args& a, bool
try {
Client::save_character_file(filename, a.c->system_file(), ch.character);
send_text_message(a.c, "$C7Character data saved\n(full save file)");
log_account_sync_backup_saved();
} catch (const std::exception& e) {
send_text_message_fmt(a.c, "$C6Character data could\nnot be saved:\n{}", e.what());
}
@@ -546,6 +558,7 @@ static asio::awaitable<void> server_command_bbchar_savechar(const Args& a, bool
try {
Client::save_character_file(filename, a.c->system_file(), bb_player);
send_text_message(a.c, "$C7Character data saved\n(basic only)");
log_account_sync_backup_saved();
} catch (const std::exception& e) {
send_text_message_fmt(a.c, "$C6Character data could\nnot be saved:\n{}", e.what());
}
+36
View File
@@ -15,6 +15,7 @@
#include "SendCommands.hh"
#include "Server.hh"
#include "Version.hh"
#include "AccountSync.hh"
const uint64_t CLIENT_CONFIG_MAGIC = 0x8399AC32;
@@ -232,6 +233,13 @@ Client::~Client() {
if ((this->version() == Version::BB_V4) && (this->character_data.get())) {
this->save_all();
if (this->login && this->login->account && this->login->bb_license) {
AccountSync::notify_bb_login_end(
this->login->account->account_id,
this->login->bb_license->username,
this->bb_character_index);
}
}
this->log.info_f("Deleted");
}
@@ -503,6 +511,13 @@ void Client::save_system_file() const {
std::string filename = this->system_filename();
phosg::save_object_file(filename, *this->system_data);
this->log.info_f("Saved system file {}", filename);
if (this->login && this->login->account && this->login->bb_license) {
AccountSync::notify_player_state_saved(
"system_saved",
this->login->account->account_id,
this->login->bb_license->username,
filename);
}
}
// Guild Card file
@@ -542,6 +557,13 @@ void Client::save_guild_card_file() const {
std::string filename = this->guild_card_filename();
phosg::save_object_file(filename, *this->guild_card_data);
this->log.info_f("Saved Guild Card file {}", filename);
if (this->login && this->login->account && this->login->bb_license) {
AccountSync::notify_player_state_saved(
"guild_card_saved",
this->login->account->account_id,
this->login->bb_license->username,
filename);
}
}
// Character file
@@ -634,6 +656,13 @@ void Client::save_character_file() {
auto filename = this->character_filename();
this->save_character_file(filename, this->system_data, this->character_data);
this->log.info_f("Saved character file {}", filename);
if (this->login && this->login->account && this->login->bb_license) {
AccountSync::notify_player_state_saved(
"character_saved",
this->login->account->account_id,
this->login->bb_license->username,
filename);
}
}
void Client::create_character_file(
@@ -834,6 +863,13 @@ void Client::save_bank_file() const {
auto filename = this->bank_filename();
this->save_bank_file(filename, *this->bank_data);
this->log.info_f("Saved bank file {}", filename);
if (this->login && this->login->account && this->login->bb_license) {
AccountSync::notify_player_state_saved(
"bank_saved",
this->login->account->account_id,
this->login->bb_license->username,
filename);
}
}
void Client::change_bank(ssize_t index) {
+9
View File
@@ -27,6 +27,7 @@
#include "StaticGameData.hh"
#include "Text.hh"
#include "BrutalPeeps.hh"
#include "AccountSync.hh"
const char* BATTLE_TABLE_DISCONNECT_HOOK_NAME = "battle_table_state";
const char* QUEST_BARRIER_DISCONNECT_HOOK_NAME = "quest_barrier";
@@ -1672,6 +1673,14 @@ static asio::awaitable<void> on_93_BB(std::shared_ptr<Client> c, Channel::Messag
co_return;
}
if (c->login->account && c->login->bb_license) {
AccountSync::notify_bb_login_start(
c->login->account->account_id,
c->login->bb_license->username,
c->bb_character_index,
c->bb_connection_phase);
}
std::string version_string = c->bb_client_config.as_string();
phosg::strip_trailing_zeroes(version_string);
// If the version std::string starts with "Ver.", assume it's Sega and apply the normal version encoding logic. Otherwise,
+2
View File
@@ -18,6 +18,7 @@
#include "SendCommands.hh"
#include "Text.hh"
#include "TextIndex.hh"
#include "AccountSync.hh"
#ifdef PHOSG_WINDOWS
static constexpr bool IS_WINDOWS = true;
@@ -879,6 +880,7 @@ void ServerState::load_config_early() {
this->client_ping_interval_usecs = this->config_json->get_int("ClientPingInterval", 30000000);
this->client_idle_timeout_usecs = this->config_json->get_int("ClientIdleTimeout", 60000000);
this->patch_client_idle_timeout_usecs = this->config_json->get_int("PatchClientIdleTimeout", 300000000);
AccountSync::configure_from_json(this->config_json->get("AccountSync", phosg::JSON::dict()));
this->psopeeps_dcv2_exp_multiplier = this->config_json->get_int("PsoPeepsDCV2EXPMultiplier", 5);
if ((this->psopeeps_dcv2_exp_multiplier != 5) && (this->psopeeps_dcv2_exp_multiplier != 10)) {
throw std::runtime_error("PsoPeepsDCV2EXPMultiplier must be 5 or 10");