Add client code for adding and reading remote leaderboard scores. Combine leaderboards API server functions into one file. Add case to test program with a mock database and mock security credentials for testing remote leaderboard connection. Add a compilation flag to indicate that PHP is available to test the API connection.
1045 lines
37 KiB
C++
1045 lines
37 KiB
C++
#include "cloud.hpp"
|
|
|
|
namespace
|
|
{
|
|
bool _http_initialized { false };
|
|
}
|
|
|
|
void sb::cloud::init([[maybe_unused]] float steam_callback_frequency, [[maybe_unused]] bool initialize_steam)
|
|
{
|
|
/* Emscripten doesn't need to initialize the HTTP library. Other builds need to initialize cURL. */
|
|
#if defined(__CURL__)
|
|
/* Initialize cURL and store result. Initialize the multi request handler. */
|
|
CURLcode result { curl_global_init(CURL_GLOBAL_DEFAULT) };
|
|
if (result != CURLE_OK)
|
|
{
|
|
_http_initialized = false;
|
|
sb::Log::Multi(sb::Log::WARN) << "cURL failed to initialize and will not be available " <<
|
|
curl_easy_strerror(result) << sb::Log::end;
|
|
}
|
|
else
|
|
{
|
|
_http_initialized = true;
|
|
}
|
|
#else
|
|
_http_initialized = true;
|
|
#endif
|
|
|
|
/* Android builds should try to copy in CA certificates for SSL requests because these are not provided by
|
|
* Android. This is untested code that was copied over from another project. The CURLSSLOPT_NATIVE_CA option may
|
|
* be available on Android now, see https://curl.se/libcurl/c/CURLOPT_SSL_OPTIONS.html */
|
|
#if defined(__ANDROID__) || defined(ANDROID)
|
|
/* Copy CA bundle from the APK assets folder to internal storage. */
|
|
if (SDL_AndroidGetInternalStoragePath() != nullptr)
|
|
{
|
|
/* Copy the certificates into the internal storage. If successfully copied, save the path. This needs to be
|
|
* updated to use a parameter passed in directly instead of from the configuration. */
|
|
// ca_bundle_path = sb::copy_file(
|
|
// configuration()["scan"]["certificate-authorities-file"], SDL_AndroidGetInternalStoragePath())
|
|
if (!fs::exists(ca_bundle_path))
|
|
{
|
|
sb::Log::Line() << "Could not copy certificate authorities file, SSL peer verification will be disabled.";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sb::Log::sdl_error("Could not access Android internal storage, SSL peer verification will be disabled.");
|
|
}
|
|
#endif
|
|
|
|
#if defined(STEAM_ENABLED)
|
|
if (initialize_steam)
|
|
{
|
|
/* Initialize Steam */
|
|
steam::init(steam_callback_frequency);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void sb::cloud::quit()
|
|
{
|
|
_http_initialized = false;
|
|
|
|
/* Quit cURL */
|
|
#if defined(__CURL__)
|
|
curl_global_cleanup();
|
|
#endif
|
|
|
|
/* Quit Steam */
|
|
#if defined(STEAM_ENABLED)
|
|
steam::shutdown();
|
|
#endif
|
|
}
|
|
|
|
/* The entire HTTP library is unavailable if HTTP is not enabled. */
|
|
|
|
#if defined(HTTP_ENABLED)
|
|
|
|
sb::cloud::HTTP::HTTP()
|
|
{
|
|
#if defined(__CURL__)
|
|
if (initialized())
|
|
{
|
|
/* Initialize a new multi handle for this object */
|
|
curl_multi_handle.reset(curl_multi_init());
|
|
}
|
|
#endif
|
|
}
|
|
|
|
bool sb::cloud::HTTP::initialized()
|
|
{
|
|
return _http_initialized;
|
|
}
|
|
|
|
void sb::cloud::HTTP::get(const Request& request)
|
|
{
|
|
launch(request, "GET");
|
|
}
|
|
|
|
void sb::cloud::HTTP::post(const Request& request)
|
|
{
|
|
launch(request, "POST");
|
|
}
|
|
|
|
void sb::cloud::HTTP::launch(const HTTP::Request& original, const std::string& method)
|
|
{
|
|
/* Copy request into a new object. The pointer will be stored by the requests vector, guaranteeing its lifetime to
|
|
* last until the requests are processed and deleted. */
|
|
Request* request { new Request(original) };
|
|
requests.push_back(request);
|
|
|
|
/* Headers will be copied into the underlying HTTP request object for the Fetch API or cURL */
|
|
std::vector<std::string> headers = request->headers();
|
|
|
|
/* POST data must be a string which will able to be decoded as application/json */
|
|
if (method == "POST")
|
|
{
|
|
/* Add Content-Type to header */
|
|
headers.push_back("Content-Type");
|
|
headers.push_back("application/json");
|
|
}
|
|
|
|
/* Add authentication */
|
|
if (!request->authorization().empty())
|
|
{
|
|
headers.push_back("Authorization");
|
|
headers.push_back("Basic " + request->authorization());
|
|
}
|
|
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Fetching data from " << request->url() << sb::Log::end;
|
|
|
|
/* Emscripten uses its built in Fetch API */
|
|
#if defined(__EMSCRIPTEN__)
|
|
|
|
/* Create a fetch attributes object. Set callbacks for when response is received. The callbacks will get the request
|
|
* pointer, so they can call the request's own callback. */
|
|
emscripten_fetch_attr_t attr;
|
|
emscripten_fetch_attr_init(&attr);
|
|
strcpy(attr.requestMethod, method.c_str());
|
|
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
|
|
attr.onsuccess = fetch_success;
|
|
attr.onerror = fetch_error;
|
|
attr.userData = request;
|
|
attr.timeoutMSecs = int(request->timeout() * 1'000);
|
|
|
|
/* Add POST data */
|
|
if (method == "POST")
|
|
{
|
|
attr.requestData = request->data().data();
|
|
attr.requestDataSize = request->data().size() * sizeof(unsigned char);
|
|
}
|
|
|
|
/* Copy headers into a vector of C strings with null terminator for Emscripten */
|
|
if (!headers.empty())
|
|
{
|
|
std::vector<const char*>* emscripten_formatted_headers = new std::vector<const char*>();
|
|
for (const std::string& component : headers)
|
|
{
|
|
const std::string* component_c = new std::string(component.c_str());
|
|
emscripten_formatted_headers->push_back(component_c->c_str());
|
|
}
|
|
emscripten_formatted_headers->push_back(nullptr);
|
|
attr.requestHeaders = emscripten_formatted_headers->data();
|
|
|
|
/* Print the headers to the debug log */
|
|
std::ostringstream message;
|
|
message << "Sending headers to " << request->url() << ": ";
|
|
for (const char* component : *emscripten_formatted_headers)
|
|
{
|
|
if (component == nullptr)
|
|
{
|
|
break;
|
|
}
|
|
else
|
|
{
|
|
message << " " << component;
|
|
}
|
|
}
|
|
sb::Log::Line(sb::Log::DEBUG) << message.str();
|
|
}
|
|
|
|
emscripten_fetch(&attr, request->url().c_str());
|
|
|
|
#else
|
|
|
|
if (initialized())
|
|
{
|
|
CURL* curl_easy_handle = curl_easy_init();
|
|
|
|
if (curl_easy_handle)
|
|
{
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_URL, request->url().c_str());
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_WRITEFUNCTION, curl_write_response);
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_TIMEOUT_MS, int(request->timeout() * 1'000));
|
|
|
|
#if defined(_WIN32)
|
|
/* Use the operating system's native CA store for certificate validation. This option seems to be necessary
|
|
* on Windows. It shouldn't be problematic on other builds, but it is not available in older versions of
|
|
* cURL, which are still being used in some cases. This may work on Android but hasn't been tested yet. See
|
|
* https://curl.se/libcurl/c/CURLOPT_SSL_OPTIONS.html */
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_SSL_OPTIONS, static_cast<long>(CURLSSLOPT_NATIVE_CA));
|
|
#endif
|
|
|
|
/* WRITEDATA is set to the request object, so the storage in the request object can receive the transfer
|
|
* data */
|
|
CURLcode result { curl_easy_setopt(curl_easy_handle, CURLOPT_WRITEDATA, reinterpret_cast<void*>(request)) };
|
|
if (result != CURLE_OK)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Setting cURL handle's CURLOPT_WRITEDATA failed: " <<
|
|
curl_easy_strerror(result) << sb::Log::end;
|
|
}
|
|
|
|
/* PRIVATE is also set to the request object, so the request can call the response function when the multi
|
|
* handle has determined that all the transfer packets are complete */
|
|
result = curl_easy_setopt(curl_easy_handle, CURLOPT_PRIVATE, reinterpret_cast<void*>(request));
|
|
if (result != CURLE_OK)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Setting cURL handle's CURLOPT_PRIVATE failed: " <<
|
|
curl_easy_strerror(result) << sb::Log::end;
|
|
}
|
|
|
|
/* Add POST data */
|
|
if (method == "POST")
|
|
{
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_POSTFIELDS, request->data().c_str());
|
|
}
|
|
|
|
/* Pass submitted headers to cURL */
|
|
struct curl_slist* list { nullptr };
|
|
if (!headers.empty())
|
|
{
|
|
/* cURL expects headers as a list of "name: value" pair strings, so combine every two components of the
|
|
* headers list into a single string */
|
|
for (std::size_t ii = 0; ii < headers.size(); ii += 2)
|
|
{
|
|
std::string pair = headers[ii] + ": " + headers[ii + 1];
|
|
list = curl_slist_append(list, pair.c_str());
|
|
}
|
|
}
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_HTTPHEADER, list);
|
|
|
|
/* Add error string buffer */
|
|
request->attach_error_buffer(curl_easy_handle);
|
|
|
|
/* Android builds are untested, this code was copied over from a previous project */
|
|
#if defined(__ANDROID__) || defined(ANDROID)
|
|
/* CA certificates bundle path must be specified in Android. */
|
|
CURLcode res;
|
|
bool success { false };
|
|
std::ostringstream message;
|
|
if (fs::exists(ca_bundle_path))
|
|
{
|
|
res = curl_easy_setopt(curl_easy_handle, CURLOPT_CAINFO, ca_bundle_path.c_str());
|
|
if (res == CURLE_OK)
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Set curl handle's CURLOPT_CAINFO to " << ca_bundle_path <<
|
|
sb::Log::end;
|
|
success = true;
|
|
}
|
|
else
|
|
{
|
|
message << "Setting curl handle's CURLOPT_CAINFO failed " << curl_easy_strerror(res);
|
|
}
|
|
}
|
|
|
|
/* Fallback to turning off SSL peer verification if the certificates file isn't available. */
|
|
if (!success)
|
|
{
|
|
sb::Log::Line(sb::Log::WARN) << "Turning off SSL peer verification";
|
|
curl_easy_setopt(curl_easy_handle, CURLOPT_SSL_VERIFYPEER, 0);
|
|
}
|
|
#endif
|
|
|
|
/* Add the easy handle to the multi handle, so it can be processed asynchronously in the update loop */
|
|
curl_multi_add_handle(curl_multi_handle.get(), curl_easy_handle);
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "cURL request initialization failed for " << request->url() <<
|
|
sb::Log::end;
|
|
}
|
|
}
|
|
|
|
#endif
|
|
}
|
|
|
|
#if defined(__EMSCRIPTEN__)
|
|
|
|
void sb::cloud::HTTP::fetch_success(emscripten_fetch_t* fetch)
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Found " << fetch->numBytes << " bytes using Emscripten Fetch API" <<
|
|
sb::Log::end;
|
|
|
|
/* Store the bytes in the request object */
|
|
HTTP::Request* request { reinterpret_cast<Request*>(fetch->userData) };
|
|
request->store(reinterpret_cast<const std::uint8_t*>(fetch->data), fetch->numBytes);
|
|
|
|
/* Set status code to success and call the user supplied callback */
|
|
request->status(fetch->status);
|
|
if (request->callback() != nullptr)
|
|
{
|
|
request->callback()(*request);
|
|
}
|
|
request->complete(true);
|
|
|
|
emscripten_fetch_close(fetch);
|
|
}
|
|
|
|
void sb::cloud::HTTP::fetch_error(emscripten_fetch_t* fetch)
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Failed fetching " << fetch->url << " with status code " << fetch->status <<
|
|
sb::Log::end;
|
|
|
|
/* Set failed status code and call the user supplied callback */
|
|
Request* request = reinterpret_cast<Request*>(fetch->userData);
|
|
request->status(fetch->status);
|
|
if (request->callback() != nullptr)
|
|
{
|
|
request->callback()(*request);
|
|
}
|
|
request->complete(true);
|
|
|
|
emscripten_fetch_close(fetch);
|
|
}
|
|
|
|
#else
|
|
|
|
std::size_t sb::cloud::HTTP::curl_write_response(
|
|
std::uint8_t* buffer, std::size_t size, std::size_t count, void* request_void)
|
|
{
|
|
std::size_t packet_size { size * count };
|
|
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Found " << packet_size << " bytes using cURL " << sb::Log::end;
|
|
|
|
/* Store the bytes in the request object */
|
|
Request* request { reinterpret_cast<Request*>(request_void) };
|
|
request->store(buffer, packet_size);
|
|
|
|
return packet_size;
|
|
}
|
|
#endif
|
|
|
|
void sb::cloud::HTTP::update([[maybe_unused]] float timestamp)
|
|
{
|
|
if (initialized())
|
|
{
|
|
#if !defined(__EMSCRIPTEN__)
|
|
/* Calling multi perform will perform transfers on handles cURL has determined are ready for transfer. Any
|
|
* handles that have transferred all their necessary data will cause a CURLMSG_DONE message to be in the message
|
|
* queue. If a handle is done, call the request callback that has been attached to it, remove the handle, and
|
|
* clean it up. See https://curl.se/libcurl/c/curl_multi_info_read.html */
|
|
int running_handles { 0 };
|
|
CURLMcode multi_result { curl_multi_perform(curl_multi_handle.get(), &running_handles) };
|
|
if (multi_result != CURLM_OK)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Error while running curl_multi_perform: error code #" << multi_result <<
|
|
sb::Log::end;
|
|
}
|
|
struct CURLMsg* message_queue { nullptr };
|
|
do
|
|
{
|
|
int message_count { 0 };
|
|
message_queue = curl_multi_info_read(curl_multi_handle.get(), &message_count);
|
|
if (message_queue != nullptr && (message_queue->msg == CURLMSG_DONE))
|
|
{
|
|
/* Get request object from the curl handle */
|
|
void* request_void { nullptr };
|
|
CURLcode result { curl_easy_getinfo(message_queue->easy_handle, CURLINFO_PRIVATE, &request_void) };
|
|
if (result == CURLE_OK)
|
|
{
|
|
Request* request { reinterpret_cast<Request*>(request_void) };
|
|
|
|
/* Store the status in the request object */
|
|
long status { 0 };
|
|
curl_easy_getinfo(message_queue->easy_handle, CURLINFO_RESPONSE_CODE, &status);
|
|
request->status(status);
|
|
|
|
/* Print a debug message */
|
|
std::ostringstream message;
|
|
message << request->url() << " returned HTTP response " << status;
|
|
if (!request->error().empty())
|
|
{
|
|
message << ": " << request->error();
|
|
}
|
|
sb::Log::Line(sb::Log::DEBUG) << message.str();
|
|
|
|
/* The request finished, call the callback and mark complete. */
|
|
if (request->callback() != nullptr)
|
|
{
|
|
request->callback()(*request);
|
|
}
|
|
request->complete(true);
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Unable to retrieve result object from cURL handle: " <<
|
|
curl_easy_strerror(result) << sb::Log::end;
|
|
}
|
|
|
|
/* Clean up the cURL handle */
|
|
curl_multi_remove_handle(curl_multi_handle.get(), message_queue->easy_handle);
|
|
curl_easy_cleanup(message_queue->easy_handle);
|
|
}
|
|
} while(message_queue != nullptr);
|
|
#endif
|
|
}
|
|
|
|
/* Delete and erase finished requests from the vector using iterators to erase while reading the vector */
|
|
for (auto iter = requests.begin(); iter != requests.end();)
|
|
{
|
|
if ((*iter)->complete())
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Freeing and removing request object for " << (*iter)->url() <<
|
|
sb::Log::end;
|
|
|
|
/* Free the heap allocated Request */
|
|
delete *iter;
|
|
|
|
/* Get a iterator that points to the next request, which may have been moved after erase */
|
|
iter = requests.erase(iter);
|
|
}
|
|
else
|
|
{
|
|
/* Only increment the iterator when there was no erase */
|
|
iter++;
|
|
}
|
|
}
|
|
}
|
|
|
|
std::size_t sb::cloud::HTTP::count() const
|
|
{
|
|
return requests.size();
|
|
}
|
|
|
|
sb::cloud::HTTP::Request& sb::cloud::HTTP::queue(std::size_t index)
|
|
{
|
|
try
|
|
{
|
|
return *requests.at(index);
|
|
}
|
|
catch (const std::out_of_range& error)
|
|
{
|
|
std::ostringstream message;
|
|
message << "Request #" << index << " does not exist in the queue";
|
|
throw std::out_of_range(message.str());
|
|
}
|
|
}
|
|
|
|
void sb::cloud::HTTP::post_analytics(
|
|
const std::string& url,
|
|
nlohmann::json analytics,
|
|
const std::string& game_title,
|
|
const std::string& game_version,
|
|
const std::string& platform,
|
|
const std::string& save_data_id,
|
|
const std::string& authorization,
|
|
const std::vector<std::string>& headers)
|
|
{
|
|
if (initialized())
|
|
{
|
|
/* Add engine information */
|
|
analytics["engine version"] = sb::version;
|
|
analytics["build"] = sb::build;
|
|
|
|
/* Add game information. If the session already contains a value for a field, only overwrite that value if the
|
|
* incoming value is not empty. */
|
|
if (!game_version.empty() || analytics.value("game version", "").empty())
|
|
{
|
|
analytics["game version"] = game_version;
|
|
}
|
|
if (!game_title.empty() || analytics.value("game title", "").empty())
|
|
{
|
|
analytics["game title"] = game_title;
|
|
}
|
|
if (!platform.empty() || analytics.value("platform", "").empty())
|
|
{
|
|
analytics["platform"] = platform;
|
|
}
|
|
if (!save_data_id.empty() || analytics.value("save data id", "").empty())
|
|
{
|
|
analytics["save data id"] = save_data_id;
|
|
}
|
|
|
|
#if defined(STEAM_ENABLED)
|
|
if (steam::initialized())
|
|
{
|
|
/* Hash the Steam user name, so the player cannot be identified */
|
|
SHA256 sha256;
|
|
std::string handle { SteamAPI_ISteamFriends_GetPersonaName(SteamFriends()) };
|
|
std::string hash { sha256.hash(handle) };
|
|
analytics["steam user"] = hash.substr(0, 6);
|
|
|
|
/* If the platform is unspecified, and the Steam API is connected, it can be assumed the platform is
|
|
* Steam. */
|
|
if (analytics.value("platform", "").empty() && platform.empty())
|
|
{
|
|
analytics["platform"] = "steam";
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/* Send the session data to the remote logger */
|
|
Request request { url };
|
|
request.data(analytics);
|
|
request.authorization(authorization);
|
|
request.headers(headers);
|
|
request.callback([&](const Request& request){
|
|
if (request.status() == 200)
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Successfully posted session data to " << request.url() <<
|
|
sb::Log::end;
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Error posting session data to " << request.url() << ". " <<
|
|
request.status() << " " << request.error() << sb::Log::end;
|
|
}
|
|
});
|
|
post(request);
|
|
}
|
|
}
|
|
|
|
sb::cloud::HTTP::Request::Request(const std::string& url) : _url(url) {}
|
|
|
|
void sb::cloud::HTTP::Request::headers(const std::vector<std::string>& headers)
|
|
{
|
|
_headers = headers;
|
|
}
|
|
|
|
const std::vector<std::string>& sb::cloud::HTTP::Request::headers() const
|
|
{
|
|
return _headers;
|
|
}
|
|
|
|
const std::string& sb::cloud::HTTP::Request::url() const
|
|
{
|
|
return _url;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::timeout(float seconds)
|
|
{
|
|
_timeout = seconds;
|
|
}
|
|
|
|
float sb::cloud::HTTP::Request::timeout() const
|
|
{
|
|
return _timeout;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::data(const nlohmann::json& json)
|
|
{
|
|
_data = json.dump();
|
|
}
|
|
|
|
const std::string& sb::cloud::HTTP::Request::data() const
|
|
{
|
|
return _data;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::authorization(const std::string& key)
|
|
{
|
|
_authorization = key;
|
|
}
|
|
|
|
const std::string& sb::cloud::HTTP::Request::authorization() const
|
|
{
|
|
return _authorization;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::callback(Callback callback)
|
|
{
|
|
_callback = callback;
|
|
}
|
|
|
|
sb::cloud::HTTP::Request::Callback sb::cloud::HTTP::Request::callback()
|
|
{
|
|
return _callback;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::store(const std::uint8_t* buffer, const std::size_t& size)
|
|
{
|
|
response.insert(response.end(), buffer, buffer + size);
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Request " << url() << " has stored " << (response.size() / 100.0f) << "KB" <<
|
|
sb::Log::end;
|
|
}
|
|
|
|
int sb::cloud::HTTP::Request::status() const
|
|
{
|
|
return _status;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::status(int status)
|
|
{
|
|
_status = status;
|
|
}
|
|
|
|
bool sb::cloud::HTTP::Request::complete() const
|
|
{
|
|
return _complete;
|
|
}
|
|
|
|
void sb::cloud::HTTP::Request::complete(bool complete)
|
|
{
|
|
_complete = complete;
|
|
}
|
|
|
|
std::string sb::cloud::HTTP::Request::error() const
|
|
{
|
|
#if !defined(__EMSCRIPTEN__)
|
|
return std::string(_error);
|
|
#else
|
|
return "";
|
|
#endif
|
|
}
|
|
|
|
#if !defined(__EMSCRIPTEN__)
|
|
void sb::cloud::HTTP::Request::attach_error_buffer(CURL* handle)
|
|
{
|
|
_error[0] = '\0';
|
|
curl_easy_setopt(handle, CURLOPT_ERRORBUFFER, _error);
|
|
}
|
|
#endif
|
|
|
|
const std::vector<std::uint8_t>& sb::cloud::HTTP::Request::bytes() const
|
|
{
|
|
return response;
|
|
}
|
|
|
|
nlohmann::json sb::cloud::HTTP::Request::json() const
|
|
{
|
|
try
|
|
{
|
|
return nlohmann::json::parse(response);
|
|
}
|
|
catch (const nlohmann::json::parse_error& error)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Invalid JSON in response from " << url();
|
|
return nlohmann::json();
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
#if defined(STEAM_ENABLED)
|
|
|
|
/*!
|
|
* Because this is an unnamed namespace, the properties are not accessible outside of this file, even if this
|
|
* header is included in another file.
|
|
*/
|
|
namespace
|
|
{
|
|
sb::Animation dispatch;
|
|
bool _steam_initialized = false;
|
|
bool _overlay_active = false;
|
|
std::optional<int> _player_count;
|
|
std::uint32_t app_id;
|
|
bool _global_stats_available = false;
|
|
}
|
|
|
|
HSteamPipe steam_pipe;
|
|
|
|
void sb::cloud::steam::count_players()
|
|
{
|
|
if (initialized())
|
|
{
|
|
/* This returns a SteamAPICall_t, but it's not needed. */
|
|
SteamAPI_ISteamUserStats_GetNumberOfCurrentPlayers(SteamUserStats());
|
|
}
|
|
}
|
|
|
|
std::optional<int> sb::cloud::steam::player_count()
|
|
{
|
|
return _player_count;
|
|
}
|
|
|
|
bool sb::cloud::steam::overlay_active()
|
|
{
|
|
return _overlay_active;
|
|
}
|
|
|
|
void sb::cloud::steam::init(float callback_frequency)
|
|
{
|
|
SteamErrMsg error;
|
|
if (SteamAPI_InitFlat(&error) == k_ESteamAPIInitResult_OK)
|
|
{
|
|
SteamAPI_ManualDispatch_Init();
|
|
_steam_initialized = true;
|
|
app_id = SteamAPI_ISteamUtils_GetAppID(SteamUtils());
|
|
sb::Log::Multi() << "Initialized Steam API" << std::endl << "⮑ App ID " <<
|
|
app_id << std::endl << " Build ID " << SteamAPI_ISteamApps_GetAppBuildId(SteamApps()) << std::endl <<
|
|
" Current user ID is " << SteamAPI_ISteamUser_GetSteamID(SteamUser()) << std::endl <<
|
|
" Current user name is " << SteamAPI_ISteamFriends_GetPersonaName(SteamFriends()) << std::endl <<
|
|
" Is user logged in? " << std::boolalpha << SteamAPI_ISteamUser_BLoggedOn(SteamUser()) << sb::Log::end;
|
|
dispatch.frame_length(callback_frequency);
|
|
dispatch.play();
|
|
steam_pipe = SteamAPI_GetHSteamPipe();
|
|
SteamAPI_ISteamUserStats_RequestCurrentStats(SteamUserStats());
|
|
}
|
|
else
|
|
{
|
|
if (!SteamAPI_IsSteamRunning())
|
|
{
|
|
sb::Log::Line() << "No running Steam client detected, so could not connect to the Steam API.";
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Line() << "Steam client was detected, but could not connect the app to the Steam API.";
|
|
}
|
|
}
|
|
}
|
|
|
|
bool sb::cloud::steam::initialized()
|
|
{
|
|
return _steam_initialized;
|
|
}
|
|
|
|
void sb::cloud::steam::update(float timestamp)
|
|
{
|
|
if (dispatch.update(timestamp) && initialized())
|
|
{
|
|
SteamAPI_ManualDispatch_RunFrame(steam_pipe);
|
|
|
|
/*!
|
|
* CallbackMsg_t contains the following properties.
|
|
*
|
|
* m_hSteamUser HSteamUser The user this callback is sent to.
|
|
* m_iCallback int The unique ID of the callback (for example, SteamAPICallCompleted_t::k_iCallback)
|
|
* m_pubParam uint8* The pointer to the callback data.
|
|
* m_cubParam int The size of m_pubParam.
|
|
*
|
|
* See https://partner.steamgames.com/doc/api/ISteamUser#CallbackMsg_t for full documentation.
|
|
*/
|
|
CallbackMsg_t callback;
|
|
|
|
/*!
|
|
* It's also possible to check the status of a callback using the SteamAPICall_t object returned by the
|
|
* caller function:
|
|
*
|
|
* bool failure;
|
|
* sb::Log::Multi() << "Is call completed? " << std::boolalpha <<
|
|
* SteamAPI_ISteamUtils_IsAPICallCompleted(SteamUtils(), steam_num_players_call, &failure) <<
|
|
* " Is failure? " << failure << sb::Log::end;
|
|
*/
|
|
|
|
/* Loop over all callbacks incoming from the Steam API */
|
|
while (SteamAPI_ManualDispatch_GetNextCallback(steam_pipe, &callback))
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Received callback ID " << callback.m_iCallback << " from Steam" <<
|
|
sb::Log::end;
|
|
|
|
/* Special callback which contains another callback and results which need allocated memory. */
|
|
if (callback.m_iCallback == SteamAPICallCompleted_t::k_iCallback)
|
|
{
|
|
/*!
|
|
* SteamAPICallCompleted_t contains the following properties.
|
|
*
|
|
* m_hAsyncCall SteamAPICall_t The handle of the Steam API Call that completed
|
|
* m_iCallback int k_iCallback constant which uniquely identifies the completed callback
|
|
* m_cubParam uint32 The size in bytes of the completed callback
|
|
*
|
|
* See https://partner.steamgames.com/doc/api/ISteamUtils#SteamAPICallCompleted_t for full
|
|
* documentation.
|
|
*/
|
|
SteamAPICallCompleted_t* call_completed =
|
|
reinterpret_cast<SteamAPICallCompleted_t*>(callback.m_pubParam);
|
|
|
|
/* Allocate memory for storing result data (for example, the count of current players). */
|
|
void* temp_call_result = malloc(callback.m_cubParam);
|
|
|
|
/* Failure flag */
|
|
bool failed;
|
|
|
|
/* Fill the call result data pointer with results. */
|
|
if (SteamAPI_ManualDispatch_GetAPICallResult(
|
|
steam_pipe, call_completed->m_hAsyncCall, temp_call_result, callback.m_cubParam,
|
|
call_completed->m_iCallback, &failed))
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Got callback ID " << call_completed->m_iCallback <<
|
|
" from call result from Steam" << sb::Log::end;
|
|
|
|
if (failed)
|
|
{
|
|
std::string failure_heading = "Steam API call failure: ";
|
|
std::string failure_message;
|
|
ESteamAPICallFailure failure = SteamAPI_ISteamUtils_GetAPICallFailureReason(
|
|
SteamUtils(), call_completed->m_hAsyncCall);
|
|
if (failure == k_ESteamAPICallFailureNone)
|
|
{
|
|
failure_message = "No Failure";
|
|
}
|
|
else if (failure == k_ESteamAPICallFailureSteamGone)
|
|
{
|
|
failure_message = "The local Steam process has stopped responding, it may "
|
|
"have been forcefully closed or is frozen.";
|
|
}
|
|
else if (failure == k_ESteamAPICallFailureNetworkFailure)
|
|
{
|
|
failure_message = "The network connection to the Steam servers has been "
|
|
"lost, or was already broken.";
|
|
}
|
|
else if (failure == k_ESteamAPICallFailureInvalidHandle)
|
|
{
|
|
failure_message = "The SteamAPICall_t handle passed in no longer exists.";
|
|
}
|
|
else if (failure == k_ESteamAPICallFailureMismatchedCallback)
|
|
{
|
|
failure_message = "GetAPICallResult was called with the wrong callback type for this API call.";
|
|
}
|
|
sb::Log::Multi(sb::Log::WARN) << failure_heading << failure_message << sb::Log::end;
|
|
}
|
|
else
|
|
{
|
|
if (call_completed->m_iCallback == NumberOfCurrentPlayers_t::k_iCallback)
|
|
{
|
|
/* Store player count */
|
|
_player_count = *(static_cast<int*>(temp_call_result));
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Stored player count as " << _player_count.value() <<
|
|
sb::Log::end;
|
|
}
|
|
if (call_completed->m_iCallback == GlobalStatsReceived_t::k_iCallback)
|
|
{
|
|
GlobalStatsReceived_t* call_result {
|
|
reinterpret_cast<GlobalStatsReceived_t*>(temp_call_result) };
|
|
if (call_result->m_eResult == k_EResultOK)
|
|
{
|
|
sb::Log::Line(sb::Log::DEBUG) << "Request for global stats succeeded";
|
|
_global_stats_available = true;
|
|
}
|
|
else if (call_result->m_eResult == k_EResultInvalidState)
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Current stats request must be completed before " <<
|
|
" request for global stats can complete" << sb::Log::end;
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Request for global stats from Steam failed with " <<
|
|
"result " << call_result->m_eResult << sb::Log::end;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Failed to get API call result from Steam for callback ID " <<
|
|
call_completed->m_iCallback << sb::Log::end;
|
|
}
|
|
free(temp_call_result);
|
|
}
|
|
else if (callback.m_iCallback == GameOverlayActivated_t::k_iCallback)
|
|
{
|
|
/* Post a message that the Steam overlay was activated and store the state. */
|
|
GameOverlayActivated_t* overlay_activated = reinterpret_cast<GameOverlayActivated_t*>(
|
|
callback.m_pubParam);
|
|
|
|
/* Make sure the event is from this app */
|
|
if (app_id == overlay_activated->m_nAppID)
|
|
{
|
|
if (overlay_activated->m_bActive)
|
|
{
|
|
_overlay_active = true;
|
|
sb::Delegate::post("steam overlay activated");
|
|
}
|
|
else
|
|
{
|
|
_overlay_active = false;
|
|
sb::Delegate::post("steam overlay deactivated");
|
|
}
|
|
}
|
|
}
|
|
else if (callback.m_iCallback == UserStatsReceived_t::k_iCallback)
|
|
{
|
|
UserStatsReceived_t* user_stats_received = reinterpret_cast<UserStatsReceived_t*>(callback.m_pubParam);
|
|
|
|
/* Make sure the game ID matches the app ID */
|
|
if (app_id == user_stats_received->m_nGameID)
|
|
{
|
|
if (user_stats_received->m_eResult == k_EResultOK)
|
|
{
|
|
sb::Log::Line(sb::Log::DEBUG) << "Received user stats from Steam";
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Failed to get user stats from Steam, error code " <<
|
|
user_stats_received->m_eResult << sb::Log::end;
|
|
}
|
|
}
|
|
}
|
|
else if (callback.m_iCallback == UserStatsStored_t::k_iCallback)
|
|
{
|
|
UserStatsStored_t* user_stats_stored = reinterpret_cast<UserStatsStored_t*>(callback.m_pubParam);
|
|
|
|
/* Only process events for this game */
|
|
if (app_id == user_stats_stored->m_nGameID)
|
|
{
|
|
if (user_stats_stored->m_eResult == k_EResultOK)
|
|
{
|
|
sb::Log::Line(sb::Log::DEBUG) << "Stored stats on Steam servers";
|
|
}
|
|
else
|
|
{
|
|
sb::Log::Multi(sb::Log::WARN) << "Failed to sync stats with Steam servers, error code " <<
|
|
user_stats_stored->m_eResult << sb::Log::end;
|
|
}
|
|
}
|
|
|
|
/* Request to sync with the updated data */
|
|
SteamAPI_ISteamUserStats_RequestCurrentStats(SteamUserStats());
|
|
}
|
|
else if (callback.m_iCallback == UserAchievementStored_t::k_iCallback)
|
|
{
|
|
UserAchievementStored_t* user_achievement_stored = reinterpret_cast<UserAchievementStored_t*>(
|
|
callback.m_pubParam);
|
|
|
|
/* Only process events for this game */
|
|
if (app_id == user_achievement_stored->m_nGameID)
|
|
{
|
|
sb::Log::Multi(sb::Log::DEBUG) << "Stored achievement " <<
|
|
user_achievement_stored->m_rgchAchievementName << " on Steam server (progress " <<
|
|
user_achievement_stored->m_nCurProgress << "/" << user_achievement_stored->m_nMaxProgress <<
|
|
")";
|
|
}
|
|
}
|
|
else if (callback.m_iCallback == SteamServersConnected_t::k_iCallback)
|
|
{
|
|
sb::Log::Line() << "Connected to Steam servers";
|
|
}
|
|
else if (callback.m_iCallback == SteamServersDisconnected_t::k_iCallback)
|
|
{
|
|
sb::Log::Line(sb::Log::WARN) << "Disconnected from Steam servers";
|
|
}
|
|
|
|
/* Free the memory allocated to the callback */
|
|
SteamAPI_ManualDispatch_FreeLastCallback(steam_pipe);
|
|
}
|
|
}
|
|
}
|
|
|
|
void sb::cloud::steam::store_stats()
|
|
{
|
|
if (initialized())
|
|
{
|
|
SteamAPI_ISteamUserStats_StoreStats(SteamUserStats());
|
|
}
|
|
}
|
|
|
|
void sb::cloud::steam::request_global_stats(int days)
|
|
{
|
|
if (initialized())
|
|
{
|
|
_global_stats_available = false;
|
|
SteamAPI_ISteamUserStats_RequestGlobalStats(SteamUserStats(), days);
|
|
}
|
|
}
|
|
|
|
bool sb::cloud::steam::global_stats_available()
|
|
{
|
|
return _global_stats_available;
|
|
}
|
|
|
|
bool sb::cloud::steam::get_global_stat_total(const sb::progress::Stat& stat, float& total)
|
|
{
|
|
if (global_stats_available())
|
|
{
|
|
bool success;
|
|
|
|
/* Handle INT64 and DOUBLE stats differently, but return as a float regardless of type. */
|
|
if (stat.type() == sb::progress::Stat::FLOAT)
|
|
{
|
|
double remote;
|
|
success = SteamAPI_ISteamUserStats_GetGlobalStatDouble(SteamUserStats(), stat.id().c_str(), &remote);
|
|
total = remote;
|
|
}
|
|
else
|
|
{
|
|
long long remote;
|
|
success = SteamAPI_ISteamUserStats_GetGlobalStatInt64(SteamUserStats(), stat.id().c_str(), &remote);
|
|
total = remote;
|
|
}
|
|
|
|
return success;
|
|
}
|
|
else
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool sb::cloud::steam::get_global_stat_history(
|
|
const sb::progress::Stat& stat, std::size_t days, std::vector<float>& history)
|
|
{
|
|
if (global_stats_available())
|
|
{
|
|
bool success;
|
|
|
|
/* Handle DOUBLE and INT64 stat types separately. Create a vector reserved to hold the requested number of days.
|
|
* Pass a pointer to the vector and the remote will fill the vector with the history data. */
|
|
if (stat.type() == sb::progress::Stat::FLOAT)
|
|
{
|
|
std::vector<double> remote(days, 0.0);
|
|
int response {
|
|
SteamAPI_ISteamUserStats_GetGlobalStatHistoryDouble(
|
|
SteamUserStats(), stat.id().c_str(), remote.data(), remote.capacity() * sizeof(double))
|
|
};
|
|
success = response > 0;
|
|
if (success)
|
|
{
|
|
for (double day : remote)
|
|
{
|
|
history.push_back(day);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
std::vector<long long> remote(days, 0);
|
|
int response {
|
|
SteamAPI_ISteamUserStats_GetGlobalStatHistoryInt64(
|
|
SteamUserStats(), stat.id().c_str(), remote.data(), remote.capacity() * sizeof(long long))
|
|
};
|
|
success = response > 0;
|
|
if (success)
|
|
{
|
|
for (long long day : remote)
|
|
{
|
|
history.push_back(day);
|
|
}
|
|
}
|
|
}
|
|
return success;
|
|
}
|
|
else
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void sb::cloud::steam::shutdown()
|
|
{
|
|
_steam_initialized = false;
|
|
if (initialized())
|
|
{
|
|
SteamAPI_Shutdown();
|
|
}
|
|
}
|
|
#endif
|