mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 20:57:08 +02:00
- park-mod reload <name> [<name>...] reads a spawned park's file again and swaps its objects in place, keeping its timer; all or none, within park_limits, and players are told the park was updated - park-mod config add <name> | <mod folder> | <map> [| <key>] and park-mod config remove <name> change maps.park_mods without a restart, validated like the startup config and saved like the map pool; removing a spawned park's entry removes the park too - The client's admin "/" menu lists reload, config add and config remove - README.txt and help cover the new commands; server_park_mods tests reload (in place, timer kept, refused when not spawned or broken) and config add/remove (saved, refusals, by an admin) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD
717 lines
43 KiB
C++
717 lines
43 KiB
C++
#include "session_internal.h"
|
|
#include "Engine/Game/World/world_names.h"
|
|
#include "Extension/Multiplayer/Hud/native_player_ui.h"
|
|
#include "Extension/Multiplayer/Remote/native_audio.h"
|
|
#include "Extension/Multiplayer/Steam/steam_social.h"
|
|
#include "Extension/Multiplayer/Hud/custom_nametags.h"
|
|
#include "Extension/Multiplayer/Hud/game_ui_state.h"
|
|
#include "Extension/Profile/local_profile_runtime.h"
|
|
#include "Extension/Objects/network_object_runtime.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Core/Text/word_filter.h"
|
|
#include "Extension/Skater/physics_tuning.h"
|
|
#include <cmath>
|
|
#include <utility>
|
|
#include <algorithm>
|
|
#include <charconv>
|
|
|
|
namespace dingosdk::multiplayer::session_detail {
|
|
// ---- saved host settings ("ReSkate.Host.*" in the local profile)
|
|
void load_host_preferences(Session &s) {
|
|
auto &p = s.host_preferences;
|
|
if (p.loaded) return;
|
|
if (!profile_runtime::local_profile_store_ready()) return;
|
|
p.loaded = true;
|
|
const auto number = [](const char *key) -> std::optional<std::int64_t> {
|
|
const auto value = profile_runtime::local_value(key);
|
|
if (!value || !value->is_number()) return {};
|
|
const auto result = value->get<double>();
|
|
return std::isfinite(result) && std::abs(result) < 1e9 ? std::optional<std::int64_t>(static_cast<std::int64_t>(result)) : std::nullopt;
|
|
};
|
|
p.public_lobby = profile_runtime::local_preference("Host.Public").value_or(true);
|
|
p.password_required = profile_runtime::local_preference("Host.RequirePassword").value_or(false);
|
|
p.world_layer_sync = profile_runtime::local_preference("Host.WorldLayerSync").value_or(false);
|
|
// A limit saved before lobbies were capped comes back as the cap.
|
|
if (const auto value = number("Host.Capacity"); value && *value >= 2 && *value <= static_cast<std::int64_t>(max_players))
|
|
p.capacity = static_cast<unsigned>(std::min<std::int64_t>(*value, multiplayer_lobby_player_limit));
|
|
if (const auto value = number("Host.Tps"); value && *value > 0 && valid_multiplayer_tps(static_cast<unsigned>(*value)))
|
|
p.tps = static_cast<unsigned>(*value);
|
|
if (const auto value = profile_runtime::local_value("Host.LobbyName"); value && value->is_string() &&
|
|
value->string().size() <= 128)
|
|
p.lobby_name = value->string();
|
|
MultiplayerDistances distances;
|
|
const std::array<std::pair<const char *, int *>, 4> fields{{
|
|
{"Host.Distance.FullReturn", &distances.full_rate_return}, {"Host.Distance.HalfStart", &distances.half_rate_start},
|
|
{"Host.Distance.HalfReturn", &distances.half_rate_return}, {"Host.Distance.LowStart", &distances.low_rate_start}}};
|
|
for (const auto &[key, field] : fields)
|
|
if (const auto value = number(key)) *field = static_cast<int>(*value);
|
|
if (distances.valid()) p.distances = distances;
|
|
if (const auto value = profile_runtime::local_value("Host.VoiceRange"); value && value->is_number() &&
|
|
valid_voice_range(value->get<float>()))
|
|
p.voice_range = value->get<float>();
|
|
if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast<std::uint64_t>(*value)))
|
|
p.placement = static_cast<ObjectPlacement>(*value);
|
|
if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast<std::uint64_t>(*value)))
|
|
p.object_limit = static_cast<unsigned>(*value);
|
|
p.guest_noclip = profile_runtime::local_preference("Host.GuestNoclip").value_or(true);
|
|
p.guest_no_bail = profile_runtime::local_preference("Host.GuestNoBail").value_or(true);
|
|
p.guest_boosts = profile_runtime::local_preference("Host.GuestBoosts").value_or(true);
|
|
p.enforce_tuning = profile_runtime::local_preference("Host.EnforceTuning").value_or(true);
|
|
p.score_check = profile_runtime::local_preference("Host.ScoreCheck").value_or(true);
|
|
}
|
|
void save_host_preferences(const Session &s) {
|
|
const auto &p = s.host_preferences;
|
|
profile_runtime::set_local_values({
|
|
{"Host.Public", p.public_lobby},
|
|
{"Host.RequirePassword", p.password_required},
|
|
{"Host.WorldLayerSync", p.world_layer_sync},
|
|
{"Host.Capacity", static_cast<std::int64_t>(p.capacity)},
|
|
{"Host.Tps", static_cast<std::int64_t>(p.tps)},
|
|
{"Host.LobbyName", p.lobby_name},
|
|
{"Host.Distance.FullReturn", static_cast<std::int64_t>(p.distances.full_rate_return)},
|
|
{"Host.Distance.HalfStart", static_cast<std::int64_t>(p.distances.half_rate_start)},
|
|
{"Host.Distance.HalfReturn", static_cast<std::int64_t>(p.distances.half_rate_return)},
|
|
{"Host.Distance.LowStart", static_cast<std::int64_t>(p.distances.low_rate_start)},
|
|
{"Host.ObjectPlacement", static_cast<std::int64_t>(p.placement)},
|
|
{"Host.ObjectLimit", static_cast<std::int64_t>(p.object_limit)},
|
|
{"Host.GuestNoclip", p.guest_noclip},
|
|
{"Host.GuestNoBail", p.guest_no_bail},
|
|
{"Host.GuestBoosts", p.guest_boosts},
|
|
{"Host.EnforceTuning", p.enforce_tuning},
|
|
{"Host.ScoreCheck", p.score_check},
|
|
{"Host.VoiceRange", static_cast<double>(p.voice_range)}});
|
|
}
|
|
// Bans live in the local profile as [{"id": "<SteamID64>", "name": ..., "added": <unix>}];
|
|
// the ID is a string because a JSON number cannot hold every 64-bit value.
|
|
void load_bans(Session &s) {
|
|
if (s.bans_loaded) return;
|
|
s.bans_loaded = true;
|
|
s.bans.clear();
|
|
s.ban_ids_dirty = true;
|
|
const auto value = profile_runtime::local_value("Host.Bans");
|
|
if (!value || !value->is_array()) return;
|
|
for (const auto &entry : *value) {
|
|
if (!entry.is_object() || !entry.contains("id") || !entry.at("id").is_string()) continue;
|
|
MultiplayerBan ban;
|
|
const auto &text = entry.at("id").string();
|
|
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), ban.id);
|
|
if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || !ban.id) continue;
|
|
if (entry.contains("name") && entry.at("name").is_string() && entry.at("name").string().size() <= 128)
|
|
ban.name = entry.at("name").string();
|
|
if (entry.contains("added") && entry.at("added").is_number())
|
|
ban.added = entry.at("added").get<std::int64_t>();
|
|
if (std::none_of(s.bans.begin(), s.bans.end(), [&](const auto &b) { return b.id == ban.id; }))
|
|
s.bans.push_back(std::move(ban));
|
|
}
|
|
}
|
|
void save_bans(const Session &s) {
|
|
Json list = Json::array();
|
|
for (const auto &ban : s.bans) {
|
|
auto item = Json::object();
|
|
item["id"] = std::to_string(ban.id);
|
|
item["name"] = ban.name;
|
|
item["added"] = ban.added;
|
|
list.push_back(std::move(item));
|
|
}
|
|
profile_runtime::set_local_values({{"Host.Bans", std::move(list)}});
|
|
}
|
|
bool is_banned(Session &s, std::uint64_t id) {
|
|
load_bans(s);
|
|
// The host checks every connection each frame; keep the IDs sorted for that.
|
|
if (s.ban_ids_dirty) {
|
|
s.ban_ids_dirty = false;
|
|
s.ban_ids.clear();
|
|
for (const auto &ban : s.bans) s.ban_ids.push_back(ban.id);
|
|
std::sort(s.ban_ids.begin(), s.ban_ids.end());
|
|
}
|
|
return std::binary_search(s.ban_ids.begin(), s.ban_ids.end(), id);
|
|
}
|
|
void refresh_friends(Session &s) {
|
|
const auto social = steam_social_snapshot();
|
|
if (!social || (s.friend_revision && *s.friend_revision == social->revision)) return;
|
|
s.friend_revision = social->revision;
|
|
s.friend_ids.clear();
|
|
for (const auto &f : social->friends) s.friend_ids.push_back(f.id);
|
|
std::sort(s.friend_ids.begin(), s.friend_ids.end());
|
|
}
|
|
void publish(Session &s, const NativeFrame *local) {
|
|
// Chat stays off screen in the main and pause menus and while the game hides its UI, as
|
|
// the nametags do.
|
|
const auto ui = sample_game_ui_state(s.base);
|
|
s.game_menu = ui.in_menu || ui.ui_hidden;
|
|
MultiplayerModel view;
|
|
load_bans(s);
|
|
view.bans = s.bans;
|
|
view.voice = s.voice.model();
|
|
view.voice.allowed = s.voice_policy.allowed && (s.mode != Mode::join || s.roster_sequence != 0);
|
|
if (!view.voice.allowed) {
|
|
view.voice.transmitting = view.voice.ready = false;
|
|
view.voice.status = s.mode == Mode::join && !s.roster_sequence ? "Waiting for the host's voice policy." : "Voice chat is disabled by the host.";
|
|
}
|
|
view.tps = s.tps;
|
|
view.distances = s.distances;
|
|
view.object_placement = s.object_placement;
|
|
view.object_limit = s.object_limit;
|
|
view.object_scaling = s.object_scaling || (s.role_permissions & role_objects) || s.mode != Mode::join;
|
|
view.object_limit_own = s.mode == Mode::host || (s.role_permissions & role_objects) ? 0 : s.object_limit; // as apply_object_limit gives this game
|
|
view.objects_placed = s.mode == Mode::off ? 0 : static_cast<unsigned>(s.local_objects.objects().size());
|
|
view.guest_noclip = s.guest_noclip;
|
|
view.guest_no_bail = s.guest_no_bail;
|
|
view.guest_boosts = s.guest_boosts;
|
|
view.enforce_tuning = s.enforce_tuning;
|
|
view.tuning_status = s.mode == Mode::join && s.enforce_tuning ? physics_tuning::status() : std::string{};
|
|
view.force_world_layers = s.force_world_layers;
|
|
view.host_id = s.host_id;
|
|
const auto now = now_us();
|
|
view.client_timing = s.client_timing.snapshot(now);
|
|
const auto &t = s.transport.status();
|
|
view.password_required = s.password.has_value();
|
|
view.direct_upload_limit = s.direct_upload.limit;
|
|
view.active = s.mode != Mode::off;
|
|
view.hosting = s.mode == Mode::host;
|
|
view.map_fetching = s.fetching_since != 0;
|
|
view.echo = s.mode == Mode::echo;
|
|
view.local_id = t.local_id;
|
|
view.local_name = s.mode != Mode::off ? s.transport.name(t.local_id) : steam_social_snapshot()->local.name;
|
|
view.lobby_name = s.lobby_name;
|
|
// A dedicated server's roster capacity counts the server itself.
|
|
view.capacity = static_cast<int>(dedicated_host(s) ? s.capacity - 1 : s.capacity);
|
|
load_host_preferences(s);
|
|
view.saved_host = {s.host_preferences.loaded, s.host_preferences.public_lobby, s.host_preferences.password_required,
|
|
static_cast<int>(s.host_preferences.capacity), s.host_preferences.tps, s.host_preferences.lobby_name};
|
|
view.nametags = s.nametags;
|
|
if (const auto social = steam_social_snapshot())
|
|
view.steam_ids_shown = identity_listed(social->local.id, IdentityList::developer) ||
|
|
identity_listed(social->local.id, IdentityList::staff) ||
|
|
identity_listed(social->local.id, IdentityList::homie);
|
|
if (const auto social = steam_social_snapshot())
|
|
if (const auto mark = identity_mark(social->local.id)) {
|
|
std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark);
|
|
view.identity_animation =
|
|
*mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" :
|
|
*mark == IdentityList::centrix ? "BLUE" : *mark == IdentityList::staff ? "GREEN" : "GOLD";
|
|
// A developer's standard is the rainbow already.
|
|
view.identity_rainbow = *mark == IdentityList::staff;
|
|
const auto styles = developer_hoodie_detail::own_styles.load();
|
|
const auto standard = developer_hoodie_detail::standard_picks(*mark);
|
|
view.identity_styles.resize(styles.size());
|
|
for (std::size_t i = 0; i < styles.size(); ++i) {
|
|
// A cosmetic never given colours shows its list's own in the pickers.
|
|
// The rainbow has no colours of its own to show either.
|
|
const bool rainbow = view.identity_rainbow && rainbow_style(styles[i]);
|
|
const bool picked = !rainbow && (styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid ||
|
|
styles[i].from != styles[i].to || styles[i].from != std::array<std::uint8_t, 3>{});
|
|
const auto &from = picked ? styles[i].from : standard.first, &to = picked ? styles[i].to : standard.second;
|
|
auto &shown = view.identity_styles[i];
|
|
shown.name = mark_item_names[i];
|
|
shown.mode = rainbow ? 4 : static_cast<int>(styles[i].mode);
|
|
shown.speed = styles[i].speed;
|
|
for (std::size_t part = 0; part < 3; ++part)
|
|
shown.from[part] = static_cast<float>(from[part]) / 255.f, shown.to[part] = static_cast<float>(to[part]) / 255.f;
|
|
}
|
|
}
|
|
view.identity_tag_shown = own_tag_shown();
|
|
view.identity_items_shown = own_items_shown();
|
|
load_host_preferences(s);
|
|
view.voice_range = s.mode == Mode::host ? s.voice_range
|
|
: dedicated_host(s) ? s.roster_voice_range : s.host_preferences.voice_range;
|
|
view.dedicated = dedicated_host(s);
|
|
view.server_admin = view.dedicated && s.server_admin;
|
|
if (view.dedicated) {
|
|
view.server_role = s.role;
|
|
view.server_role_tag = s.own_tag;
|
|
view.server_all_commands = s.role_all_commands;
|
|
view.server_commands = s.role_commands;
|
|
view.server_permissions = s.role_permissions;
|
|
}
|
|
view.party = s.local_party;
|
|
view.party_leader = s.local_party_leader;
|
|
view.party_open = s.local_party_open;
|
|
view.parties = s.mode == Mode::host || s.mode == Mode::join;
|
|
for (const auto &invite : s.party_invites) {
|
|
const auto *from = find_peer(s, invite.from);
|
|
view.party_invites.push_back({invite.from, from && !from->member.name.empty() ? from->member.name
|
|
: s.transport.name(invite.from)});
|
|
}
|
|
if (server_can(view, "ban") || server_can(view, "unban") || server_can(view, "bans")) {
|
|
view.bans = s.server_bans;
|
|
view.server_bans = true;
|
|
view.server_ban_total = s.server_ban_total;
|
|
}
|
|
if (server_can(view, "map")) {
|
|
view.server_maps = s.server_maps;
|
|
view.server_map_pool = s.server_map_pool;
|
|
view.server_map_rotation = s.server_map_rotation;
|
|
view.server_map_votes = (s.server_votes & server_vote_map) != 0;
|
|
}
|
|
view.player_distance = s.player_distance;
|
|
view.prefer_direct = s.prefer_direct;
|
|
view.nametag_distance = s.nametag_distance;
|
|
view.nametag_dots = s.nametag_dots;
|
|
view.nametags_friends = s.nametags_friends;
|
|
view.chat_visible = s.chat_visible;
|
|
view.chat_filter = s.chat_filter;
|
|
view.chat_bubbles = s.chat_bubbles;
|
|
view.chat_bubbles_own = s.chat_bubbles_own;
|
|
view.chat_bubbles_distance = s.chat_bubbles_distance;
|
|
view.chat_bubbles_duration = s.chat_bubbles_duration;
|
|
view.chat_bubbles_history = s.chat_bubbles_history;
|
|
view.object_status = network_object_status();
|
|
view.players = static_cast<int>(player_count(s));
|
|
view.sent = t.sent;
|
|
view.received = t.received;
|
|
view.dropped = t.dropped;
|
|
view.network_telemetry = t.telemetry;
|
|
view.ping_ms = t.ping_ms;
|
|
view.send_rate = t.send_rate;
|
|
view.pending_bytes = t.pending_bytes;
|
|
view.outgoing_bps = t.outgoing_bps;
|
|
view.incoming_bps = t.incoming_bps;
|
|
view.delivery_local = t.delivery_local;
|
|
view.delivery_remote = t.delivery_remote;
|
|
view.queue_us = t.queue_us;
|
|
view.prioritized_connections = t.prioritized_connections;
|
|
view.cosmetic_queue_us = t.cosmetic_queue_us;
|
|
view.skipped_updates = t.skipped;
|
|
view.send_failures = t.send_failures;
|
|
view.invalid_messages = t.invalid_messages;
|
|
view.sent_bytes = t.sent_bytes;
|
|
view.raw_sent_bytes = t.raw_sent_bytes;
|
|
view.invite = s.invite;
|
|
if ((s.mode == Mode::host || s.mode == Mode::join) && s.host_id && s.secret) view.join_code = format_invite({s.host_id, s.secret});
|
|
view.map = s.map_name.empty() ? s.available_map : s.map_name;
|
|
view.local_ready = s.gameplay_ready && !s.available_map.empty();
|
|
const auto &lobby = s.lobbies.status();
|
|
view.public_host = s.public_host;
|
|
view.public_lobby = s.mode == Mode::host ? s.lobbies.hosted_lobby() : s.joined_public_lobby;
|
|
view.lobby_listed = lobby.listed;
|
|
view.lobby_searching = lobby.searching || s.servers.searching();
|
|
view.lobby_joining = lobby.joining;
|
|
view.lobby_searched = lobby.searched;
|
|
view.lobby_status = lobby.hosting;
|
|
view.browser_status = lobby.browser;
|
|
// Dedicated servers first: they are always up and never a stranger's own session.
|
|
view.lobbies = s.servers.rows();
|
|
// Looked up as the list is shown: the team's list can arrive, or change, after a server was found.
|
|
for (auto &server : view.lobbies) server.official = official_server(server.id);
|
|
view.lobbies.insert(view.lobbies.end(), lobby.rows.begin(), lobby.rows.end());
|
|
// Steam friends by the public server or lobby their game says they are in.
|
|
if (const auto social = steam_social_snapshot())
|
|
for (const auto &player : social->friends) {
|
|
if (!player.session) continue;
|
|
const auto row = std::find_if(view.lobbies.begin(), view.lobbies.end(), [&](const auto &entry) { return entry.id == player.session; });
|
|
if (row != view.lobbies.end() && row->friends.size() < 16) row->friends.push_back(player.name.empty() ? std::string("A friend") : player.name);
|
|
}
|
|
view.status = s.status;
|
|
view.native_status = s.native_status;
|
|
view.audio_captured = captured_audio_frames();
|
|
each_peer([&] {
|
|
auto &p = s.peers[peer_slot];
|
|
const auto native_count = remote_pose_updates(), board_count = remote_board_pose_updates();
|
|
const auto animation = remote_animation_stats();
|
|
view.pose_updates += native_count;
|
|
view.board_pose_updates += board_count;
|
|
view.audio_played += played_audio_frames();
|
|
view.player_map_updates += player_map_updates();
|
|
if (!p.member.id)
|
|
return;
|
|
view.connected |= p.handshaken;
|
|
if (dedicated_host(s) && p.member.id == s.host_id) {
|
|
// The server is not a player: name the session after it instead.
|
|
view.lobby_name = p.member.name;
|
|
return;
|
|
}
|
|
view.remote_visible |= p.visible;
|
|
const auto name = p.member.name.empty() ? std::to_string(p.member.id) : p.member.name;
|
|
view.roster.push_back({p.member.id,
|
|
name,
|
|
p.handshaken,
|
|
p.visible,
|
|
p.native_status,
|
|
p.cosmetic_status,
|
|
native_audio_status(),
|
|
player_ui_status(),
|
|
{},
|
|
0,
|
|
0});
|
|
view.roster.back().epoch = p.member.epoch;
|
|
view.roster.back().party = p.member.party;
|
|
view.roster.back().party_leader = p.member.party_leader;
|
|
view.roster.back().party_member = party_member(s, p.member.id);
|
|
if (!p.rate_at) {
|
|
p.rate_at = now;
|
|
p.native_rate_count = native_count;
|
|
p.board_rate_count = board_count;
|
|
p.animation_rate_base = animation;
|
|
}
|
|
if (now - p.rate_at >= 1000000) {
|
|
p.pose_hz = static_cast<float>(p.pose_count - p.rate_count) * 1000000.f /
|
|
static_cast<float>(now - p.rate_at);
|
|
const auto rate = 1000000.f / static_cast<float>(now - p.rate_at);
|
|
p.native_pose_hz = static_cast<float>(native_count - std::min(native_count, p.native_rate_count)) * rate;
|
|
p.native_board_hz = static_cast<float>(board_count - std::min(board_count, p.board_rate_count)) * rate;
|
|
const auto evaluations = animation.evaluated -
|
|
std::min(animation.evaluated, p.animation_rate_base.evaluated);
|
|
const auto applies = animation.evaluated + animation.skipped -
|
|
std::min(animation.evaluated + animation.skipped,
|
|
p.animation_rate_base.evaluated + p.animation_rate_base.skipped);
|
|
const auto evaluation_us = animation.evaluation_us -
|
|
std::min(animation.evaluation_us, p.animation_rate_base.evaluation_us);
|
|
const auto apply_us = animation.apply_us -
|
|
std::min(animation.apply_us, p.animation_rate_base.apply_us);
|
|
p.native_animation_hz = static_cast<float>(evaluations) * rate;
|
|
p.native_animation_ms = evaluations ? static_cast<float>(evaluation_us) / (1000.f * evaluations) : 0.f;
|
|
p.pose_apply_ms = applies ? static_cast<float>(apply_us) / (1000.f * applies) : 0.f;
|
|
p.native_rate_count = native_count;
|
|
p.board_rate_count = board_count;
|
|
p.animation_rate_base = animation;
|
|
p.rate_at = now;
|
|
p.rate_count = p.pose_count;
|
|
}
|
|
auto &row = view.roster.back();
|
|
row.pose_hz = p.pose_hz;
|
|
row.native_pose_hz = p.native_pose_hz;
|
|
row.native_board_hz = p.native_board_hz;
|
|
row.native_animation_hz = p.native_animation_hz;
|
|
row.native_animation_ms = p.native_animation_ms;
|
|
row.pose_apply_ms = p.pose_apply_ms;
|
|
row.native_animation_skipped = animation.skipped;
|
|
const auto playback = p.poses.playback();
|
|
row.playback = s.mode == Mode::echo ? "Buffered echo" :
|
|
playback.mode == PosePlaybackMode::buffered ? "Buffered" :
|
|
playback.mode == PosePlaybackMode::predicted ? "Predicting" :
|
|
playback.mode == PosePlaybackMode::held ? "Holding" : "Waiting";
|
|
row.prediction_ms = static_cast<unsigned>(playback.prediction_us / 1000);
|
|
row.correcting = playback.correcting;
|
|
row.pose_target_tps = 1000000 / p.received_pose_interval;
|
|
if (p.latest_root && s.local_root) {
|
|
float distance{};
|
|
for (unsigned i = 0; i < 3; ++i) {
|
|
const auto d = p.latest_root->position[i] - s.local_root->position[i];
|
|
distance += d * d;
|
|
}
|
|
row.distance_m = std::sqrt(distance);
|
|
}
|
|
row.pose_age_ms = p.pose_arrival ? (now - p.pose_arrival) / 1000 : 0;
|
|
const bool direct = s.mode == Mode::host || p.member.id == s.host_id ||
|
|
(p.direct_ready && p.last_direct_pose && now - p.last_direct_pose <= 500000);
|
|
row.route = s.mode == Mode::echo ? "Local Echo" : direct ? "Steam peer" : "Via host";
|
|
if (s.mode != Mode::echo && p.handshaken) {
|
|
if (direct)
|
|
++view.direct_connections;
|
|
else
|
|
++view.fallback_streams;
|
|
if (s.mode == Mode::host)
|
|
for (const auto &other : active_peers(s))
|
|
if (other.handshaken && other.member.id != p.member.id &&
|
|
needs_relay(p.direct_routes, p.route_reported, other.member, now))
|
|
++view.fallback_streams;
|
|
}
|
|
if (!view.peer_id) {
|
|
view.peer_id = p.member.id;
|
|
view.peer_name = name;
|
|
view.native_status = p.native_status;
|
|
view.cosmetic_status = p.cosmetic_status;
|
|
view.audio_status = row.audio_status; // this slot's, read above
|
|
view.player_ui_status = row.ui_status;
|
|
}
|
|
});
|
|
if (!s.cosmetic_capture_status.empty())
|
|
view.cosmetic_status = s.cosmetic_capture_status;
|
|
view.lobby_joining = lobby.joining || (s.mode == Mode::join && !view.connected);
|
|
if (local) {
|
|
view.local_ready = local->ready;
|
|
}
|
|
view.skater_bones = s.last_skater_bones;
|
|
view.board_bones = s.last_board_bones;
|
|
publish_chat(s); // takes the same lock, so before it
|
|
// Only this thread writes s.view, so it reads it without the lock. Log before taking
|
|
// the lock the overlay copies the model under: file and console writes never hold it.
|
|
if (view.status != s.view.status || view.players != s.view.players)
|
|
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {} Players {}/{}",
|
|
view.status, view.players, view.capacity);
|
|
if (view.lobby_status != s.view.lobby_status && !view.lobby_status.empty())
|
|
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", view.lobby_status);
|
|
if (view.connected && now - s.last_network_log >= 60000000) { // a summary a minute, not log spam
|
|
const auto local_hz = s.last_network_log
|
|
? static_cast<double>(s.local_pose_count - s.logged_local_pose_count) * 1000000. /
|
|
static_cast<double>(now - s.last_network_log) : 0.;
|
|
s.logged_local_pose_count = s.local_pose_count;
|
|
s.last_network_log = now;
|
|
logging::log(logging::Level::info, logging::Channel::runtime,
|
|
"Multiplayer network: players={}, direct={}, relay streams={}, out={:.1f} KiB/s, "
|
|
"in={:.1f} KiB/s, queue={:.1f} ms, ping={} ms, local poses={:.1f}/s, "
|
|
"direct budget={}, skipped={}, dropped={}.",
|
|
view.players, view.direct_connections, view.fallback_streams, view.outgoing_bps / 1024.f,
|
|
view.incoming_bps / 1024.f, static_cast<double>(view.queue_us) / 1000., view.ping_ms,
|
|
local_hz, view.direct_upload_limit, view.skipped_updates, view.dropped);
|
|
// One line per player every 5 s adds up in a full lobby: debug level (off by default).
|
|
if (logging::enabled(logging::Level::debug))
|
|
for (const auto &row : view.roster)
|
|
logging::log(logging::Level::debug, logging::Channel::runtime,
|
|
"Multiplayer delivery: peer={}, route={}, poses={:.1f}/s, target={} TPS, newest arrival={} ms ago, "
|
|
"native skater/board={:.1f}/{:.1f}/s, graph={:.1f}/s ({:.3f} ms), "
|
|
"pose apply={:.3f} ms, graph skips={}, playback={}, prediction={} ms, correcting={}.",
|
|
row.id, row.route, row.pose_hz, row.pose_target_tps, row.pose_age_ms,
|
|
row.native_pose_hz, row.native_board_hz, row.native_animation_hz,
|
|
row.native_animation_ms, row.pose_apply_ms, row.native_animation_skipped,
|
|
row.playback, row.prediction_ms, row.correcting);
|
|
}
|
|
std::lock_guard lock(s.mutex);
|
|
s.view = std::move(view);
|
|
}
|
|
std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
|
|
std::vector<MultiplayerChatCommand> list{
|
|
{"/help", "/help", "List these commands"},
|
|
{"/tp", "/tp <player>", "Teleport beside a player (or /tp <x> <y> <z>)", "player"},
|
|
{"/p", "/p <message>", "Talk to your party only"},
|
|
};
|
|
if (s.mode == Mode::host || s.mode == Mode::join) {
|
|
list.push_back({"/party invite", "/party invite <player>", "Invite a player to your party", "player"});
|
|
list.push_back({"/party accept", "/party accept [player]", "Join the party you were invited to", "player"});
|
|
list.push_back({"/party decline", "/party decline [player]", "Turn down a party invite", "player"});
|
|
list.push_back({"/party join", "/party join <player>", "Join a player's open party", "player"});
|
|
list.push_back({"/party leave", "/party leave", "Leave your party"});
|
|
list.push_back({"/party kick", "/party kick <player>", "Leader: remove a player from the party", "player"});
|
|
list.push_back({"/party promote", "/party promote <player>", "Leader: hand the lead to a player", "player"});
|
|
list.push_back({"/party open", "/party open", "Leader: let anyone join the party (/party close: invite only)"});
|
|
list.push_back({"/party", "/party", "Who is in your party"});
|
|
}
|
|
if (dedicated_host(s)) {
|
|
// The server's own commands (server_votes.cpp, server_host.cpp): it answers them, and
|
|
// this list is only what the "/" menu offers, so one left out here still works unseen.
|
|
list.push_back({"/w", "/w <player> <message>", "Send a player a private message", "player"});
|
|
if (s.server_votes & server_vote_map) list.push_back({"/vote map", "/vote map <map>", "Start a vote to change the map", "map"});
|
|
if (s.server_votes & server_vote_kick)
|
|
list.push_back({"/vote kick", "/vote kick <player>", "Start a vote to kick a player", "player"});
|
|
if (s.server_votes & server_vote_time)
|
|
list.push_back({"/vote tod", "/vote tod <time>", "Vote for a time of day: morning, noon, afternoon, evening, night...", "time"});
|
|
for (const auto &vote : s.server_custom_votes) {
|
|
std::string choices;
|
|
for (const auto &choice : vote.choices) choices += (choices.empty() ? "" : "|") + choice;
|
|
list.push_back({"/vote " + vote.name, "/vote " + vote.name + (choices.empty() ? "" : " <" + choices + ">"),
|
|
vote.description.empty() ? "Start a vote this server defines" : "Start a vote: " + vote.description});
|
|
}
|
|
if (s.server_votes || !s.server_custom_votes.empty()) {
|
|
list.push_back({"/yes", "/yes", "Vote yes in the running vote"});
|
|
list.push_back({"/no", "/no", "Vote no in the running vote"});
|
|
}
|
|
if (!s.server_custom_votes.empty()) list.push_back({"/vote list", "/vote list", "This server's own votes, and what each does"});
|
|
const auto polls = static_cast<ServerPolls>(s.server_polls);
|
|
if (polls == ServerPolls::everyone || (polls == ServerPolls::admins && (s.role_permissions & role_polls)))
|
|
list.push_back({"/poll", "/poll <question> | <answer> | <answer>...", "Ask everyone a question, with up to six answers"});
|
|
if (polls != ServerPolls::off) list.push_back({"/1", "/1, /2...", "Answer the running poll"});
|
|
// The server commands their role gives them (the rest it would refuse).
|
|
const auto before_commands = list.size();
|
|
if (s.server_admin) {
|
|
list.push_back({"/msg", "/msg <player> <message>", "Admin: message a player privately", "player"});
|
|
list.push_back({"/msg-party", "/msg-party <player> <message>", "Admin: message everyone in a player's party", "player"});
|
|
list.push_back({"/msg-admins", "/msg-admins <message>", "Admin: message the admins who are on"});
|
|
list.push_back({"/kick", "/kick <player>", "Admin: kick a player until the server restarts", "player"});
|
|
list.push_back({"/ban", "/ban <player>", "Admin: ban a player", "player"});
|
|
list.push_back({"/map", "/map <map>", "Admin: change the server's map", "map"});
|
|
list.push_back({"/tpall", "/tpall [player]", "Admin: teleport everyone to you (or to a player)", "player"});
|
|
list.push_back({"/tphere", "/tphere <player>", "Admin: teleport a player to you", "player"});
|
|
list.push_back({"/teleport", "/teleport <player|SteamID64,SteamID64,...> <player|x y z>",
|
|
"Admin: teleport players to a player or a place (y is up)", "player"});
|
|
list.push_back({"/tod", "/tod <time>", "Admin: set the time of day", "time"});
|
|
list.push_back({"/votes", "/votes [<vote> on|off|<percent>|seconds|cooldown|min-players <n>]", "Admin: the server's vote settings"});
|
|
list.push_back({"/announce", "/announce <text>", "Admin: announce something to everyone, on a card on their screen"});
|
|
list.push_back({"/announce-to", "/announce-to <player> <text>", "Admin: announce something to one player, on a card on their screen",
|
|
"player"});
|
|
list.push_back({"/announcements", "/announcements [list|add <text>|remove <n>|interval <minutes>|off]",
|
|
"Admin: the messages the server announces on a timer"});
|
|
list.push_back({"/poll end", "/poll end", "Admin: end the running poll now"});
|
|
list.push_back({"/vote-cancel", "/vote-cancel", "Admin: stop the running vote"});
|
|
list.push_back({"/map-pool", "/map-pool [add|remove <map>|clear]", "Admin: the maps players vote between and the rotation uses"});
|
|
list.push_back({"/rotation", "/rotation [<minutes>|off]", "Admin: change the map on a timer, through the map pool"});
|
|
list.push_back({"/role", "/role <player> <role>|none", "Admin: give a player a role, or take theirs away", "player"});
|
|
list.push_back({"/roles", "/roles", "Admin: the server's roles and who has them"});
|
|
// Park mods (Server/server_parks.cpp): their names are the server's own, not in the roster.
|
|
list.push_back({"/park-mod list", "/park-mod list", "Admin: the server's park mods, and which are spawned"});
|
|
list.push_back({"/park-mod add", "/park-mod add <name> [<name>...] [minutes]",
|
|
"Admin: spawn park mods on this map, for 1-1440 minutes or until removed"});
|
|
list.push_back({"/park-mod remove", "/park-mod remove <name> [<name>...]", "Admin: remove spawned park mods"});
|
|
list.push_back({"/park-mod clear", "/park-mod clear", "Admin: remove every spawned park mod"});
|
|
list.push_back({"/park-mod reload", "/park-mod reload <name> [<name>...]",
|
|
"Admin: read a spawned park mod's file again and update it in place"});
|
|
list.push_back({"/park-mod config add", "/park-mod config add <name> | <mod folder> | <map> [| <key>]",
|
|
"Admin: set up a park mod from the server's Mods folder, without a restart"});
|
|
list.push_back({"/park-mod config remove", "/park-mod config remove <name>", "Admin: remove a park mod from the server's setup"});
|
|
list.erase(std::remove_if(list.begin() + static_cast<std::ptrdiff_t>(before_commands), list.end(), [&](const auto &entry) {
|
|
// "/poll end" is the polls permission's; the others are named for their command, the
|
|
// first word ("/park-mod add" is park-mod's).
|
|
const std::string_view shown = entry.name;
|
|
if (shown == "/poll end") return !(s.role_permissions & role_polls);
|
|
return !role_allows(s.role_all_commands, s.role_commands, shown.substr(1, shown.find(' ') - 1));
|
|
}), list.end());
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
namespace {
|
|
// FNV-1a over the values a view is built from, to skip rebuilding an unchanged one.
|
|
struct Signature {
|
|
std::uint64_t value = 14695981039346656037ULL;
|
|
void add(std::uint64_t number) {
|
|
for (unsigned i = 0; i < 8; ++i)
|
|
value = (value ^ ((number >> (8 * i)) & 0xff)) * 1099511628211ULL;
|
|
}
|
|
void add(std::string_view text) {
|
|
for (const unsigned char c : text)
|
|
value = (value ^ c) * 1099511628211ULL;
|
|
add(static_cast<std::uint64_t>(text.size()));
|
|
}
|
|
};
|
|
} // namespace
|
|
void publish_chat(Session &s) {
|
|
const auto local = s.transport.status().local_id;
|
|
const bool dedicated = dedicated_host(s);
|
|
const auto listed = [&](const Peer &peer) {
|
|
return peer.handshaken && peer.member.id && peer.member.id != local && !(dedicated && peer.member.id == s.host_id);
|
|
};
|
|
// publish() calls this at 10 Hz, rosters and chat lines too. Rebuilding copies the 50
|
|
// lines and sorts names, so first check whether anything the view shows changed:
|
|
// the "/" list (mode, votes, admin), the players, the server's maps, visibility and
|
|
// the lines (added and dropped only, with increasing sequences).
|
|
Signature signature;
|
|
signature.add(static_cast<std::uint64_t>(s.mode));
|
|
signature.add(static_cast<std::uint64_t>(dedicated));
|
|
signature.add(static_cast<std::uint64_t>(s.server_votes));
|
|
signature.add(static_cast<std::uint64_t>(s.server_admin));
|
|
signature.add(static_cast<std::uint64_t>(s.role_all_commands) | static_cast<std::uint64_t>(s.role_permissions) << 1);
|
|
for (const auto &command : s.role_commands) signature.add(std::hash<std::string>{}(command));
|
|
signature.add(std::hash<std::string>{}(s.role + '|' + s.own_tag));
|
|
signature.add(static_cast<std::uint64_t>(s.chat_visible));
|
|
signature.add(static_cast<std::uint64_t>(s.chat_filter));
|
|
signature.add(static_cast<std::uint64_t>(s.game_menu));
|
|
for (const auto &peer : active_peers(s))
|
|
if (listed(peer)) signature.add(peer.member.name.empty() ? s.transport.name(peer.member.id) : peer.member.name);
|
|
for (const auto &asset : s.server_maps) signature.add(asset);
|
|
// The vote card: its tally, the player's answer, and the seconds left as they pass.
|
|
const auto now = now_us();
|
|
const unsigned vote_seconds = s.vote.id && s.vote.outcome == vote_running && s.vote_ends > now
|
|
? static_cast<unsigned>((s.vote_ends - now + 999999) / 1000000) : 0;
|
|
signature.add(static_cast<std::uint64_t>(s.vote.id));
|
|
signature.add(static_cast<std::uint64_t>(s.vote.yes) << 32 | static_cast<std::uint64_t>(s.vote.no) << 16 | s.vote.needed);
|
|
signature.add(static_cast<std::uint64_t>(s.vote.outcome) << 40 | static_cast<std::uint64_t>(s.vote_mine) << 32 | vote_seconds);
|
|
for (const auto count : s.vote.counts) signature.add(static_cast<std::uint64_t>(count));
|
|
signature.add(static_cast<std::uint64_t>(s.server_polls));
|
|
for (const auto &vote : s.server_custom_votes) signature.add(vote.name);
|
|
// The announcement card, and the seconds it still shows.
|
|
const unsigned announcement_seconds = s.announcement.id && s.announcement_ends > now
|
|
? static_cast<unsigned>((s.announcement_ends - now + 999999) / 1000000) : 0;
|
|
signature.add(static_cast<std::uint64_t>(s.announcement.id) << 16 | announcement_seconds);
|
|
signature.add(static_cast<std::uint64_t>(s.chat.size()));
|
|
if (!s.chat.empty()) {
|
|
signature.add(s.chat.front().sequence);
|
|
signature.add(s.chat.back().sequence);
|
|
}
|
|
if (s.chat_signature == signature.value) return;
|
|
s.chat_signature = signature.value;
|
|
MultiplayerChat view;
|
|
view.commands = chat_commands(s);
|
|
// Argument completion: the other players, and the dedicated server's maps.
|
|
for (const auto &peer : active_peers(s))
|
|
if (listed(peer))
|
|
view.players.push_back(peer.member.name.empty() ? s.transport.name(peer.member.id) : peer.member.name);
|
|
for (const auto &asset : s.server_maps) view.maps.push_back(world_level_name(asset));
|
|
std::sort(view.players.begin(), view.players.end());
|
|
std::sort(view.maps.begin(), view.maps.end());
|
|
view.maps.erase(std::unique(view.maps.begin(), view.maps.end()), view.maps.end());
|
|
// Hidden chat is not offered at all: no lines on screen and T does nothing. A game menu
|
|
// (or the game's hidden UI) also closes an open chat box.
|
|
view.available = (s.mode == Mode::host || s.mode == Mode::join) && s.chat_visible && !s.game_menu;
|
|
view.latest = s.chat.empty() ? 0 : s.chat.back().sequence;
|
|
if (dedicated && s.vote.id) {
|
|
auto &vote = view.vote;
|
|
vote.id = s.vote.id;
|
|
vote.label = clean_chat_text(s.vote.label);
|
|
vote.yes = s.vote.yes;
|
|
vote.no = s.vote.no;
|
|
vote.needed = s.vote.needed;
|
|
vote.seconds = vote_seconds;
|
|
vote.outcome = s.vote.outcome;
|
|
vote.mine = s.vote_mine;
|
|
vote.may_vote = s.vote.target != s.transport.status().local_id;
|
|
vote.poll = s.vote.kind == server_vote_poll;
|
|
for (const auto &answer : s.vote.answers) vote.answers.push_back(clean_chat_text(answer));
|
|
vote.counts.assign(s.vote.counts.begin(), s.vote.counts.end());
|
|
const auto binds = local_profile_controller_bindings();
|
|
if (binds.available) {
|
|
vote.yes_bind = binds.vote_yes_combo;
|
|
vote.no_bind = binds.vote_no_combo;
|
|
}
|
|
}
|
|
if (dedicated && announcement_seconds) view.announcement = {s.announcement.id, clean_chat_text(s.announcement.text), announcement_seconds};
|
|
view.lines.assign(s.chat.begin(), s.chat.end());
|
|
// The filter masks each line once; lines leave the cache with the log.
|
|
if (!s.chat_filter || s.chat.empty()) {
|
|
s.chat_masked.clear();
|
|
} else {
|
|
s.chat_masked.erase(s.chat_masked.begin(), s.chat_masked.lower_bound(s.chat.front().sequence));
|
|
for (auto &line : view.lines) {
|
|
auto [found, added] = s.chat_masked.try_emplace(line.sequence);
|
|
if (added) found->second = {text::mask_bad_words(line.name), text::mask_bad_words(line.text)};
|
|
line.name = found->second.first;
|
|
if (found->second.second != line.text) line.unmasked = std::exchange(line.text, found->second.second);
|
|
}
|
|
}
|
|
std::lock_guard lock(s.mutex);
|
|
s.chat_view = std::move(view);
|
|
}
|
|
std::pair<std::uint32_t, std::string> mark_role(IdentityList list) {
|
|
switch (list) {
|
|
case IdentityList::developer: return {nametag_developer, "Dev"};
|
|
case IdentityList::content_creator: return {nametag_creator, "Content Creator"};
|
|
case IdentityList::centrix: return {nametag_centrix, "Centrix"};
|
|
case IdentityList::staff: return {nametag_staff, "Staff"};
|
|
default: return {nametag_homie, "Homie"};
|
|
}
|
|
}
|
|
bool identity_link(Session &s, std::uint64_t other) {
|
|
if (identity_mark(other) || identity_mark(s.transport.status().local_id)) return true;
|
|
refresh_friends(s);
|
|
return std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), other);
|
|
}
|
|
// The colour and tag a player gets, in chat and on their nametag. `marks` off leaves out who
|
|
// the backend says they are: for a chat line that is not known to be theirs (chat proofs,
|
|
// session_receive.cpp).
|
|
std::pair<std::uint32_t, std::string> player_role(Session &s, std::uint64_t sender, bool local, bool marks) {
|
|
if (!sender) return {};
|
|
const bool dedicated = dedicated_host(s);
|
|
if (dedicated && sender == s.host_id) return {s.server_chat_badge, "Server"}; // the server itself
|
|
const auto *peer = local ? nullptr : find_peer(s, sender);
|
|
const bool vouched = local || (peer && steam_vouched(s, *peer));
|
|
// Who the backend says a player is comes before what they are in this lobby, unless they
|
|
// have turned their tag off (the Special page), which their appearance tells everyone.
|
|
if (marks && vouched && (local ? own_tag_shown() : shows_tag(*peer)))
|
|
if (const auto mark = identity_mark(sender)) return mark_role(*mark);
|
|
// On a dedicated server: the tag the player's role there gives them, in its colour.
|
|
if (dedicated) {
|
|
const auto &worn = local ? s.own_tag : peer ? peer->member.tag : std::string{};
|
|
if (!worn.empty()) return {(local ? s.own_tag_color : peer->member.tag_color) | 0xff000000U, worn};
|
|
}
|
|
if (!dedicated && (local ? s.mode == Mode::host : sender == s.host_id)) return {nametag_host, "Host"};
|
|
if (!local && vouched) {
|
|
refresh_friends(s);
|
|
if (std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), sender)) return {nametag_friend, "Friend"};
|
|
}
|
|
return {nametag_white, {}};
|
|
}
|
|
void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local, bool marks) {
|
|
if (name.empty()) name = sender ? "Player" : "ReSkate";
|
|
auto [color, tag] = player_role(s, sender, local, marks);
|
|
// The dedicated server's own lines (its chat, and its answers sent to this player alone) stand out.
|
|
const bool server = dedicated_host(s) && (sender ? sender == s.host_id : name == "Server");
|
|
if (server && !sender) {
|
|
color = s.server_chat_badge;
|
|
tag = "Server";
|
|
}
|
|
s.chat.push_back({++s.chat_sequence, sender, now_us(), std::move(name), std::move(text), local, color, std::move(tag), {}, server,
|
|
server ? s.server_chat_text : 0U});
|
|
while (s.chat.size() > multiplayer_chat_history) s.chat.pop_front();
|
|
publish_chat(s);
|
|
}
|
|
} // namespace dingosdk::multiplayer::session_detail
|