mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-19 05:25:25 +02:00
302 lines
13 KiB
C++
302 lines
13 KiB
C++
#include "steam_lobbies.h"
|
|
#include "Engine/Core/Text/word_filter.h"
|
|
#include "Engine/Game/Build/supported_build.h"
|
|
#include <algorithm>
|
|
#include <charconv>
|
|
#include <stdexcept>
|
|
#include <utility>
|
|
|
|
namespace dingosdk::multiplayer {
|
|
namespace {
|
|
bool safe_text(std::string_view value, std::size_t limit) {
|
|
return !value.empty() && value.size() <= limit &&
|
|
std::none_of(value.begin(), value.end(), [](unsigned char c) { return c < 32 || c == 127; });
|
|
}
|
|
void require(bool value, const char *why) {
|
|
if (!value)
|
|
throw std::runtime_error(why);
|
|
}
|
|
} // namespace
|
|
std::optional<MultiplayerLobby> read_lobby(std::uint64_t id, std::uint64_t owner,
|
|
const std::function<std::string(const char *)> &get) {
|
|
if (!id || get("rs_kind") != "reskate-co-skate" ||
|
|
get("rs_protocol") != std::to_string(protocol_version) ||
|
|
get("rs_build") != supported_build::game_sha256 || get("rs_open") != "1")
|
|
return {};
|
|
MultiplayerLobby row{id, owner, get("rs_name"), get("rs_map"), get("rs_code"), false, 1};
|
|
const auto code = parse_invite(row.code);
|
|
auto number = [&](const char *key, int &out) {
|
|
const auto text = get(key);
|
|
const auto value = std::from_chars(text.data(), text.data() + text.size(), out);
|
|
return value.ec == std::errc{} && value.ptr == text.data() + text.size();
|
|
};
|
|
// GetLobbyOwner is only valid AFTER joining. Browsers read the advertised
|
|
// host from metadata; the join completion checks against the actual owner.
|
|
if (!code || (owner && code->steam_id != owner) || !safe_text(row.map, 256) ||
|
|
!safe_text(row.name, 128) || !number("rs_players", row.players) ||
|
|
!number("rs_capacity", row.capacity) || row.capacity < 2 ||
|
|
row.capacity > static_cast<int>(max_players) || row.players < 1 || row.players >= row.capacity)
|
|
return {};
|
|
const auto password = get("rs_password");
|
|
if (password != "0" && password != "1")
|
|
return {};
|
|
row.password_required = password == "1";
|
|
row.owner = code->steam_id;
|
|
return row;
|
|
}
|
|
void SteamLobbies::close_lobby() {
|
|
if (const auto lobby = std::exchange(lobby_, 0)) {
|
|
api_->joinable(lobby, false);
|
|
api_->leave(lobby);
|
|
}
|
|
state_.listed = false;
|
|
published_.clear();
|
|
}
|
|
void SteamLobbies::stop() {
|
|
host_wanted_ = ready_ = false;
|
|
creating_.cancelled = joining_.cancelled = true;
|
|
state_.joining = false;
|
|
joined_.reset();
|
|
close_lobby();
|
|
state_.hosting.clear();
|
|
}
|
|
void SteamLobbies::host(const std::string &code, unsigned capacity, bool password_required, std::string_view name) {
|
|
require(name.empty() || safe_text(name, 128), "Lobby names must be at most 128 bytes with no control characters.");
|
|
stop();
|
|
const auto invite = parse_invite(code);
|
|
require(invite.has_value(), "Host join code is invalid.");
|
|
require(capacity >= 2 && capacity <= multiplayer_lobby_player_limit, "Invalid lobby capacity.");
|
|
password_required_ = password_required;
|
|
capacity_ = capacity;
|
|
players_ = 1;
|
|
code_ = code;
|
|
host_name_ = name;
|
|
local_id_ = invite->steam_id;
|
|
host_wanted_ = true;
|
|
failed_ = false;
|
|
state_.hosting = "Public lobby: waiting for a loaded map.";
|
|
}
|
|
void SteamLobbies::publish_host() {
|
|
if (!lobby_)
|
|
return;
|
|
require(api_->owner(lobby_) == local_id_, "Steam lobby ownership changed. Restart hosting.");
|
|
const bool open = ready_ && players_ < capacity_;
|
|
const auto signature = map_ + code_ + std::to_string(players_) + "/" + std::to_string(capacity_) +
|
|
(open ? "open" : "closed");
|
|
if (signature == published_)
|
|
return;
|
|
// Hide first, then publish a complete compatible record before exposing it.
|
|
require(api_->joinable(lobby_, false), "Steam could not close the lobby listing.");
|
|
state_.listed = false;
|
|
const std::pair<const char *, std::string> values[]{
|
|
{"rs_kind", "reskate-co-skate"},
|
|
{"rs_protocol", std::to_string(protocol_version)},
|
|
{"rs_build", std::string(supported_build::game_sha256)},
|
|
{"rs_name", host_name_},
|
|
{"rs_map", map_},
|
|
{"rs_code", code_},
|
|
{"rs_players", std::to_string(players_)},
|
|
{"rs_capacity", std::to_string(capacity_)},
|
|
{"rs_password", password_required_ ? "1" : "0"},
|
|
{"rs_open", open ? "1" : "0"}};
|
|
for (const auto &[key, value] : values)
|
|
require(api_->data(lobby_, key, value), "Steam could not publish the lobby details.");
|
|
require(api_->visibility(lobby_, true), "Steam could not make the lobby public.");
|
|
if (open)
|
|
require(api_->joinable(lobby_, true), "Steam could not open the lobby listing.");
|
|
state_.listed = open;
|
|
state_.hosting = !ready_ ? "Host is changing maps; the public listing will reopen after loading."
|
|
: !open ? "Public lobby is full; it will reopen when a player leaves."
|
|
: "Public lobby is listed on Steam.";
|
|
published_ = signature;
|
|
}
|
|
void SteamLobbies::update_host(bool ready, unsigned players, std::string_view map, std::uint64_t now) {
|
|
if (!host_wanted_ || failed_)
|
|
return;
|
|
players = std::clamp(players, 1U, capacity_);
|
|
// The session calls this every frame. With nothing changed since the listing was
|
|
// published there is nothing to write; still confirm ownership once a second.
|
|
if (lobby_ && ready && ready_ && players == players_ && map == map_ && !published_.empty() &&
|
|
now >= owner_checked_ && now - owner_checked_ < 1000000)
|
|
return;
|
|
ready_ = ready;
|
|
players_ = players;
|
|
map_ = map;
|
|
try {
|
|
if (!ready_) {
|
|
if (lobby_ && (state_.listed || !published_.empty())) {
|
|
require(api_->joinable(lobby_, false), "Steam could not pause the lobby listing.");
|
|
require(api_->data(lobby_, "rs_open", "0"), "Steam could not pause lobby discovery.");
|
|
state_.listed = false;
|
|
published_.clear();
|
|
state_.hosting = "Host is changing maps; existing players stay connected.";
|
|
}
|
|
return;
|
|
}
|
|
require(safe_text(map_, 256), "Load a supported map before publishing a lobby.");
|
|
if (!lobby_ && !creating_.call) {
|
|
api_->open();
|
|
if (host_name_.empty()) host_name_ = api_->name();
|
|
if (!safe_text(host_name_, 128))
|
|
host_name_ = "ReSkate session";
|
|
creating_ = {api_->create(capacity_), now, false};
|
|
require(creating_.call != 0, "Steam could not create a lobby.");
|
|
state_.hosting = "Creating a public Steam lobby...";
|
|
}
|
|
publish_host();
|
|
owner_checked_ = now;
|
|
} catch (const std::exception &e) {
|
|
failed_ = true;
|
|
close_lobby();
|
|
state_.hosting = std::string("Public listing failed: ") + e.what() + " Join code still works.";
|
|
}
|
|
}
|
|
void SteamLobbies::refresh(std::uint64_t now) {
|
|
if (searching_.call || (last_search_ && now - last_search_ < 5000000))
|
|
return;
|
|
try {
|
|
api_->open();
|
|
searching_ = {api_->search(), now, false};
|
|
require(searching_.call != 0, "Steam could not request the lobby list.");
|
|
last_search_ = now;
|
|
state_.searching = true;
|
|
state_.rows.clear();
|
|
state_.browser = "Searching Steam for open ReSkate lobbies...";
|
|
} catch (const std::exception &e) {
|
|
state_.browser = e.what();
|
|
}
|
|
}
|
|
void SteamLobbies::join(std::uint64_t id, std::uint64_t local_id, std::uint64_t now) {
|
|
require(!joining_.call, "A previous lobby request is still finishing.");
|
|
require(id != 0, "Select a lobby before joining.");
|
|
api_->open();
|
|
const auto row =
|
|
std::find_if(state_.rows.begin(), state_.rows.end(), [id](const auto &v) { return v.id == id; });
|
|
require(row != state_.rows.end(), "Lobby is no longer in the browser. Refresh the list.");
|
|
require(row->owner != local_id, "You cannot join your own lobby.");
|
|
friend_join_ = false;
|
|
join_map_ = row->map;
|
|
join_owner_ = row->owner;
|
|
join_code_ = row->code;
|
|
join_id_ = id;
|
|
joining_ = {api_->join(id), now, false};
|
|
require(joining_.call != 0, "Steam could not join the lobby.");
|
|
state_.joining = true;
|
|
state_.browser = "Checking that the lobby is still open...";
|
|
}
|
|
void SteamLobbies::join_friend(std::uint64_t id, std::uint64_t local_id, std::uint64_t now) {
|
|
require(!joining_.call, "A previous lobby request is still finishing.");
|
|
require(id && local_id, "That Steam lobby is unavailable.");
|
|
api_->open();
|
|
friend_join_ = true;
|
|
local_id_ = local_id;
|
|
join_id_ = id;
|
|
join_owner_ = 0;
|
|
join_map_.clear(); join_code_.clear();
|
|
joining_ = {api_->join(id), now, false};
|
|
require(joining_.call != 0, "Steam could not join the lobby.");
|
|
state_.joining = true;
|
|
state_.browser = "Checking your friend's lobby...";
|
|
}
|
|
void SteamLobbies::tick(std::uint64_t now) {
|
|
if (now - last_poll_ < 100000)
|
|
return;
|
|
last_poll_ = now;
|
|
auto poll = [&](Pending &pending, LobbyCall kind, auto &&done) {
|
|
if (!pending.call)
|
|
return;
|
|
if (const auto result = api_->poll(pending.call, kind)) {
|
|
const bool cancelled = pending.cancelled;
|
|
pending = {};
|
|
done(*result, cancelled);
|
|
} else if (!pending.cancelled && now - pending.since > 25000000) {
|
|
// Do not forget timed-out create/join calls: a late successful result
|
|
// still joins a lobby, so drain it and leave even after cancellation.
|
|
pending.cancelled = true;
|
|
if (kind == LobbyCall::create) {
|
|
failed_ = true;
|
|
state_.hosting = "Public listing timed out. Join code still works; restart hosting to retry.";
|
|
} else {
|
|
state_.searching = kind == LobbyCall::list ? false : state_.searching;
|
|
state_.joining = kind == LobbyCall::join ? false : state_.joining;
|
|
state_.browser = "Steam lobby request timed out. Waiting for Steam to finish cleanup.";
|
|
}
|
|
}
|
|
};
|
|
poll(creating_, LobbyCall::create, [&](const LobbyResult &result, bool cancelled) {
|
|
if (cancelled || !host_wanted_) {
|
|
if (result.ok && result.lobby)
|
|
api_->leave(result.lobby);
|
|
return;
|
|
}
|
|
if (!result.ok || !result.lobby) {
|
|
failed_ = true;
|
|
state_.hosting =
|
|
"Steam lobby creation failed (" + std::to_string(result.result) + "). Join code still works.";
|
|
return;
|
|
}
|
|
lobby_ = result.lobby;
|
|
// Metadata/visibility are published by update_host after current map and
|
|
// connection readiness have been evaluated on this client tick.
|
|
});
|
|
poll(searching_, LobbyCall::list, [&](const LobbyResult &result, bool cancelled) {
|
|
state_.searching = false;
|
|
if (cancelled)
|
|
return;
|
|
state_.searched = true;
|
|
state_.rows.clear();
|
|
if (!result.ok) {
|
|
state_.browser = "Steam lobby search failed. Refresh to retry.";
|
|
return;
|
|
}
|
|
// Lobbies named with bad words are never listed, like dedicated servers; the host
|
|
// can still share the join code.
|
|
for (std::uint32_t i = 0; i < std::min(result.count, 50U); ++i) {
|
|
const auto id = api_->at(static_cast<int>(i));
|
|
const auto row = read_lobby(id, 0, [&](const char *key) { return api_->data(id, key); });
|
|
if (row && !text::contains_bad_words(row->name) &&
|
|
std::none_of(state_.rows.begin(), state_.rows.end(),
|
|
[id](const auto &v) { return v.id == id; }))
|
|
state_.rows.push_back(*row);
|
|
}
|
|
state_.browser = state_.rows.empty()
|
|
? "No compatible public lobbies found."
|
|
: std::to_string(state_.rows.size()) + " open public lobbies found.";
|
|
});
|
|
poll(joining_, LobbyCall::join, [&](const LobbyResult &result, bool cancelled) {
|
|
state_.joining = false;
|
|
struct Leave {
|
|
LobbyApi &api;
|
|
std::uint64_t id;
|
|
~Leave() {
|
|
if (id)
|
|
api.leave(id);
|
|
}
|
|
} leave{*api_, result.lobby};
|
|
if (cancelled)
|
|
return;
|
|
if (!result.ok || result.lobby != join_id_) {
|
|
state_.browser = "Lobby is unavailable or full. Refresh and try again.";
|
|
return;
|
|
}
|
|
const auto owner = api_->owner(result.lobby);
|
|
const auto row =
|
|
read_lobby(result.lobby, owner, [&](const char *key) { return api_->data(result.lobby, key); });
|
|
if (!owner || !row || (friend_join_ ? owner == local_id_ :
|
|
owner != join_owner_ || row->code != join_code_ || map_hash(row->map) != map_hash(join_map_))) {
|
|
state_.browser = "Lobby changed or closed. Refresh the list before joining.";
|
|
return;
|
|
}
|
|
if (friend_join_ && row->password_required) {
|
|
state_.browser = "This lobby needs a password. Join it from the server browser.";
|
|
return;
|
|
}
|
|
// The lobby is a discovery directory. Actual membership/capacity follows
|
|
// the P2P connection, including guests who use the direct join code.
|
|
joined_ = row;
|
|
state_.browser = "Lobby verified. Connecting to the host...";
|
|
});
|
|
}
|
|
std::optional<MultiplayerLobby> SteamLobbies::take_join() { return std::exchange(joined_, {}); }
|
|
} // namespace dingosdk::multiplayer
|