Files
ReSkate/Extension/Multiplayer/Session/session_view.cpp
T

672 lines
39 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.server_admin || s.mode != Mode::join;
view.object_limit_own = s.mode == Mode::host || s.server_admin ? 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.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())
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;
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 (view.server_admin) {
view.bans = s.server_bans;
view.server_bans = true;
view.server_ban_total = s.server_ban_total;
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.server_admin))
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"});
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({"/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"});
}
}
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.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);
if (dedicated && (local ? s.server_admin : peer && peer->member.admin)) return {nametag_admin, "Admin"};
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