diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d820c35..3e007eef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,6 +113,24 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread) endif() add_test(NAME server_park_mods COMMAND dingosdk_server_park_tests "${PROJECT_SOURCE_DIR}/Server/Test/fixtures/park_mods") + # Tools locked for single players (lock, throwdown_locks), as each player's game reads its roster. + add_executable(dingosdk_server_lock_tests Server/Test/server_lock_tests.cpp Server/server_host.cpp Server/server_party.cpp + Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp + $<$:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp + Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp + Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp + Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp) + target_include_directories(dingosdk_server_lock_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_include_directories(dingosdk_server_lock_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") + if(WIN32) + target_link_libraries(dingosdk_server_lock_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm) + else() + target_link_libraries(dingosdk_server_lock_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread) + endif() + add_test(NAME server_locks COMMAND dingosdk_server_lock_tests) add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp) target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json) diff --git a/Server/README-roles.txt b/Server/README-roles.txt index 46fdcb99..66fcb1ac 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -91,7 +91,8 @@ Extras: unlimited_objects No limit on placed objects; can build when placement is "admins"; can resize objects when object-scaling is off. movement Can use noclip, teleport, No Bail and the boosts when the - server has turned them off for players. + server has turned them off for players. Not while one of + them is locked for the player (lock, throwdown_locks). no_afk_kick Is never removed for being away (afk-kick). vote_kick_immune Cannot be the target of a kick vote. anticheat_alerts Is told in chat when the anti-cheat finds something (for @@ -190,6 +191,12 @@ What players may do party-size party-size <2-8>. afk-kick afk-kick |off. announce-throwdowns announce-throwdowns on|off. + lock lock : take noclip, + nobail, boosts or tuning from one player. + unlock unlock []. + locks The players with locked tools. + throwdown-locks throwdown-locks |off: what a throwdown locks for + its players. Votes and polls votes Every vote setting: which votes are on, their percent, diff --git a/Server/README.txt b/Server/README.txt index 61e8810b..a69f5c14 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -228,6 +228,15 @@ sync_effects Let players see each other's skater effects (default true): server. Console: effects on|off. announce_throwdowns Tell everyone in chat when a throwdown drop is placed (default true). +throwdown_locks Tools locked for the players of a throwdown while it runs, + for fair games on a server that allows them for free + skating (default [], none): any of "noclip" (with + teleporting), "nobail", "boosts", "tuning" (their own + physics tuning). They are locked when the throwdown starts + and unlocked when the player quits it, it ends (the server + hears nothing more of it for a minute or so) or they leave. + Example: ["nobail", "boosts", "tuning"]. Console: + throwdown-locks |off. See lock below. "anti_cheat" - What the server checks, and what it does about it. speed_hack Catch players whose game runs faster than normal (Cheat @@ -486,7 +495,8 @@ A role is: unlimited_objects no object limit; build and resize when players may not movement noclip, No Bail and the boosts when - players may not + players may not (not a tool that is + locked for them: lock) no_afk_kick never removed for being away vote_kick_immune cannot be kicked by a vote anticheat_alerts told in chat what the anti-cheat finds @@ -557,6 +567,22 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. noclip on|off nobail on|off boosts on|off What players may use (admins always can). tuning on|off Everyone on the game's own physics tuning. + lock + Take tools from one player: noclip (with + teleporting), nobail, boosts, tuning (their own + physics tuning), or all. Everyone else keeps what + the server allows, and the player's role cannot + give a locked tool back (movement). Takes effect + at once; kept in memory until unlocked or the + player leaves. Logged as "[lock] + (): nobail, boosts locked by ". + unlock [] + Give them back (all when none are named); also + ends a throwdown's locks early. Logged as + "[lock] ... unlocked by ". + locks The players with locked tools. + throwdown-locks |off What a throwdown locks for its players + (players.throwdown_locks). tpall [player] Everyone to you (admins in game) or to a player. tphere One player to you (admins in game). teleport diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 35a6ccff..91164cd7 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -169,6 +169,25 @@ int run() { const auto odd = load_config(file); check(odd.score_check == "warn" && odd.score_allow == std::vector{0xabc}, "Bad scoring settings not cleaned"); + // Throwdown locks: off by default, kept by name, unknown names dropped. + check(!reloaded.throwdown_locks, "Throwdown locks are not off by default"); + auto locks = reloaded; + locks.throwdown_locks = lock_nobail | lock_tuning; + save_config(locks); + { + std::ifstream in(file, std::ios::binary); + const std::string written((std::istreambuf_iterator(in)), {}); + check(written.find(R"("throwdown_locks": [)") != std::string::npos && written.find(R"("nobail")") != std::string::npos, + "Throwdown locks not written as names"); + } + check(load_config(file).throwdown_locks == (lock_nobail | lock_tuning), "Throwdown locks lost"); + std::ofstream(file, std::ios::binary) << R"({"players": {"throwdown_locks": ["boosts", "jetpack", 3, "teleport"]}})"; + check(load_config(file).throwdown_locks == (lock_boosts | lock_noclip), "Bad throwdown locks not cleaned"); + check(parse_lock_tools("all") == lock_all && parse_lock_tools(" NoBail , tuning") == (lock_nobail | lock_tuning) && + !parse_lock_tools("") && !parse_lock_tools("nobail,") && !parse_lock_tools("fly"), + "Lock tool names not read"); + check(lock_tools_text(lock_all) == "noclip, nobail, boosts, tuning" && lock_tools_text(0) == "none", "Lock tool names not written"); + // A config from before the sections: every setting is read from where it was, under the name // it had, and the file is written back in sections, in reading order, with nothing called new // that was only moved. diff --git a/Server/Test/server_lock_tests.cpp b/Server/Test/server_lock_tests.cpp new file mode 100644 index 00000000..88025fe1 --- /dev/null +++ b/Server/Test/server_lock_tests.cpp @@ -0,0 +1,421 @@ +// Tools locked for single players (lock, unlock, locks, throwdown_locks): each player's roster +// and permissions as their game reads them, with the server over a transport that is not +// Steam's and simulated players. Players without locks get what everyone got before. +// +// dingosdk_server_lock_tests +#include "Server/server_host.h" +#include "Engine/Game/World/world_names.h" +#include "Extension/Throwdowns/throwdown_wire.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/sound_codec.h" +#include "Extension/Multiplayer/Net/wire_codec.h" +#include "Extension/Multiplayer/Session/object_state.h" +#include "Extension/Multiplayer/Session/room.h" +#include "Extension/Multiplayer/Steam/steam_transport.h" +#include "Engine/Game/Build/supported_build.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::multiplayer { +namespace { +// What the fake transport holds: what each player is sent, and what they send. +struct Wire { + TransportStatus status; + std::vector inbound; + std::map>> sent; + std::map closed; +}; +Wire &wire() { + static Wire value; + return value; +} +} // namespace + +// A game server's SteamID, as Steam gives the server one: never a player's. +constexpr std::uint64_t server_steam_id = (1ULL << 56) | (4ULL << 52) | 1; + +struct SteamTransport::Impl {}; +SteamTransport::SteamTransport() = default; +SteamTransport::~SteamTransport() = default; +bool SteamTransport::open() { return true; } +bool SteamTransport::open_game_server(void *) { return true; } +bool SteamTransport::host(unsigned) { + wire().status.ready = wire().status.hosting = true; + wire().status.local_id = server_steam_id; + return true; +} +bool SteamTransport::join(std::uint64_t, std::uint32_t, std::uint16_t) { return false; } +bool SteamTransport::listen_direct(std::uint16_t) { return true; } +void SteamTransport::set_packing(unsigned) {} +std::vector SteamTransport::take_direct_notes() { return {}; } +bool SteamTransport::set_steam_debug(bool) { return false; } +bool SteamTransport::connect_peer(std::uint64_t) { return false; } +void SteamTransport::allow_peers(std::span) {} +bool SteamTransport::socket_test() { return true; } +void SteamTransport::stop() {} +void SteamTransport::disconnect(std::uint64_t id, const char *reason) { + wire().closed[id] = reason ? reason : ""; + std::erase_if(wire().status.peers, [&](const TransportPeer &peer) { return peer.id == id; }); +} +void SteamTransport::poll() {} +bool SteamTransport::send(std::uint64_t id, std::span bytes, bool, bool, TrafficLane) { + if (std::none_of(wire().status.peers.begin(), wire().status.peers.end(), [&](const auto &peer) { return peer.id == id; })) return false; + wire().sent[id].emplace_back(bytes.begin(), bytes.end()); + return true; +} +void SteamTransport::send_batch(std::span messages) { + for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane); +} +std::vector SteamTransport::receive() { return std::exchange(wire().inbound, {}); } +std::string SteamTransport::name(std::uint64_t) { return {}; } +const TransportStatus &SteamTransport::status() const { return wire().status; } +std::string SteamTransport::take_closed(std::uint64_t) { return {}; } +std::string SteamTransport::link_report(std::uint64_t) { return {}; } +std::int64_t SteamTransport::pending(std::uint64_t) const { return 0; } +std::string SteamTransport::relay_status() const { return "OK"; } +bool SteamTransport::set_send_rate(int) { return true; } +int SteamTransport::send_rate() const { return 900 * 1024; } +std::vector SteamTransport::links() { return {}; } +bool SteamTransport::bind(void *, void *, void *) { return true; } +} // namespace dingosdk::multiplayer + +namespace { +using namespace dingosdk; +using namespace dingosdk::multiplayer; +using namespace dingosdk::server; + +int failures{}; +void check(bool ok, const std::string &what) { + if (!ok) { + std::fprintf(stderr, "FAIL: %s\n", what.c_str()); + ++failures; + } +} +bool has(const std::vector &lines, std::string_view text) { + return std::any_of(lines.begin(), lines.end(), [&](const auto &line) { return line.find(text) != std::string::npos; }); +} + +// One player's game, as far as the tools go: the roster's tool flags and the permissions its +// role is sent with, as its session reads them (session_receive.cpp). +struct Game { + std::uint64_t id{}, epoch{}, secret{}, map{}, world = 1; + std::uint32_t sequence{}; + std::string name; + DeltaReceiver receiver; + bool joined{}; + unsigned rosters{}; + bool noclip{}, no_bail{}, boosts{}, enforce_tuning{}; + std::uint32_t announcement{}; + std::optional permissions; + std::vector chat; + + Packet header(PacketKind kind, std::uint64_t now) { + Packet p; + p.kind = kind; + p.session = secret; + p.map = map; + p.world = world; + p.epoch = epoch; + p.source = id; + p.time_us = now; + p.sequence = ++sequence; + p.build = supported_build::game_sha256_bytes; + return p; + } + void send(const Packet &p) { wire().inbound.push_back({id, encode_wire(p), p.time_us}); } + void hello(std::uint64_t now) { + wire().status.peers.push_back({id, true}); + auto p = header(PacketKind::hello, now); + p.text = name; + send(p); + } + void leave() { + std::erase_if(wire().status.peers, [&](const TransportPeer &peer) { return peer.id == id; }); + } + void say(std::string_view text, std::uint64_t now) { + auto p = header(PacketKind::chat, now); + p.text = std::string(text); + send(p); + } + void ready(std::uint64_t now) { + auto p = header(PacketKind::world_ready, now); + p.world_ready = true; + send(p); + } + void throwdown(const ThrowdownMessage &message, std::uint64_t now) { + auto p = header(PacketKind::throwdown, now); + p.throwdown = encode_throwdown(message); + send(p); + } + // What their game lets them use: the roster's flag, or their role's "movement". + bool may_noclip() const { return noclip || (permissions && (*permissions & role_movement)); } + bool may_no_bail() const { return no_bail || (permissions && (*permissions & role_movement)); } + bool may_boost() const { return boosts || (permissions && (*permissions & role_movement)); } + void read() { + for (const auto &bytes : std::exchange(wire().sent[id], {})) { + if (pose_batch::is_batch(bytes) || pose_batch::Ack::read(bytes) || sound_codec::is_sound(bytes)) continue; + bool missing{}; + const auto p = receiver.receive(bytes, missing, 0); + if (!p) continue; + if (p->kind == PacketKind::welcome) joined = true; + if (p->kind == PacketKind::world_state) { + world = p->world; + if (p->map) map = p->map; + } + if (p->kind == PacketKind::roster) { + ++rosters; + noclip = p->guest_noclip; + no_bail = p->guest_no_bail; + boosts = p->guest_boosts; + enforce_tuning = p->enforce_tuning; + announcement = p->announcement.id; + } + if (p->kind == PacketKind::permissions) permissions = p->role_permissions; + if (p->kind == PacketKind::chat) chat.push_back(p->text); + } + } +}; + +int run() { + const auto empty = std::filesystem::temp_directory_path() / "reskate_server_lock_tests_mods"; + std::filesystem::create_directories(empty); + load_levels(empty); // the retail maps + ServerConfig config; + config.file = std::filesystem::temp_directory_path() / "reskate_server_lock_tests" / "ReSkateServer.json"; + config.mods = empty; + config.auto_update = false; + // A server for free skating: every tool allowed, everyone's own physics. + config.noclip = config.no_bail = config.boosts = true; + config.enforce_tuning = false; + config.roles = default_roles(); + Role trainer; // "movement" without commands: noclip and the rest even where players may not + trainer.name = "trainer"; + trainer.rank = 5; + trainer.permissions = role_movement; + config.roles.list.push_back(trainer); + config.roles.settle(); + const std::uint64_t admin_id = 76561198000000001ULL, b_id = 76561198000000002ULL, c_id = 76561198000000003ULL, + d_id = 76561198000000004ULL; + config.roles.players[admin_id] = "admin"; + config.roles.players[d_id] = "trainer"; + check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); + + std::vector log; + SteamTransport transport; + Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); + std::string error; + if (!host.start(error)) { + std::fprintf(stderr, "The server did not start: %s\n", error.c_str()); + return 1; + } + const auto invite = parse_invite(host.invite()); + if (!invite) { + std::fprintf(stderr, "No invite.\n"); + return 1; + } + std::map games; + const auto add_game = [&](std::uint64_t id, std::string name) -> Game & { + auto &game = games[id]; + game = {}; + game.id = id; + game.epoch = 5000 + id % 1000 + games.size(); + game.secret = invite->secret; + game.map = map_hash(map_destination(config.map)); + game.name = std::move(name); + return game; + }; + std::uint64_t now = 1000000000ULL, next_ready = now; + const auto run_for = [&](std::uint64_t microseconds) { + for (const auto end = now + microseconds; now < end; now += 50000) { + if (now >= next_ready) { + next_ready = now + 1000000; + for (auto &[id, game] : games) + if (game.joined) game.ready(now); + } + host.tick(now); + for (auto &[id, game] : games) game.read(); + } + }; + const auto command = [&](std::string_view line, std::uint64_t admin = 0) { + const auto reply = host.command(line, admin); + if (!admin) log.push_back(reply); + run_for(500000); // the next roster and permissions go out + return reply; + }; + const auto logged = [&](std::string_view text) { return has(log, text); }; + // Every tool the server allows, and its own physics off: what a player had before locks. + const auto free = [](const Game &g) { return g.noclip && g.no_bail && g.boosts && !g.enforce_tuning; }; + + auto &a = add_game(admin_id, "Robin"); + auto &b = add_game(b_id, "Bailey Two"); + auto &c = add_game(c_id, "Casey"); + auto &d = add_game(d_id, "Dana"); + for (auto *g : {&a, &b, &c, &d}) g->hello(now); + run_for(3000000); + for (auto *g : {&a, &b, &c, &d}) check(g->joined && g->rosters && g->permissions, g->name + " did not join"); + + // ---- Without locks: everyone has what the server allows ---------------------------------- + for (auto *g : {&a, &b, &c, &d}) check(free(*g), g->name + "'s roster is not the server's own without locks"); + check(a.permissions && (*a.permissions & role_movement) && d.permissions && (*d.permissions & role_movement), + "A role's movement was not sent without locks"); + auto reply = command("locks"); + check(reply == "0 players have locked tools", "locks with none: " + reply); + + // ---- lock: one player's roster says the tools are off; nobody else's changes -------------- + reply = command("lock Bailey Two nobail,boosts"); // a name with a space, the tools last + check(reply == "Locked for Bailey Two: nobail, boosts.", "lock reply: " + reply); + check(logged("[lock] Bailey Two (76561198000000002): nobail, boosts locked by console"), "The lock was not logged"); + check(b.noclip && !b.no_bail && !b.boosts && !b.enforce_tuning, "Bailey's roster does not have nobail and boosts locked"); + check(!b.may_no_bail() && !b.may_boost() && b.may_noclip(), "Bailey's game would still allow the locked tools"); + check(has(b.chat, "The server locked for you: nobail, boosts."), "Bailey was not told"); + for (auto *g : {&a, &c, &d}) check(free(*g), g->name + "'s roster changed with someone else's lock"); + check(command("lock 76561198000000002 nobail") == "Bailey Two already has nobail locked.", "A second lock of the same tool changed something"); + + // ---- tuning: their own physics stand down; their role's movement is untouched ------------- + reply = command("lock Casey tuning"); + check(reply == "Locked for Casey: tuning.", "lock tuning: " + reply); + check(c.enforce_tuning && c.noclip && c.no_bail && c.boosts, "Casey's roster does not enforce the tuning, or lost a tool"); + check(command("lock Dana tuning") == "Locked for Dana: tuning.", "lock Dana tuning"); + check(d.permissions && (*d.permissions & role_movement), "A tuning lock took a role's movement away"); + + // ---- A role with movement cannot lift a lock ----------------------------------------------- + command("noclip off"); + command("nobail off"); + check(!c.noclip && !c.no_bail && c.boosts && d.may_noclip() && d.may_no_bail(), "noclip/nobail off did not reach the players"); + reply = command("lock Dana nobail"); + check(reply == "Locked for Dana: nobail.", "lock Dana nobail: " + reply); + check(d.permissions && !(*d.permissions & role_movement), "A locked player's permissions still have movement"); + check(!d.may_no_bail(), "Dana's role lifted the No Bail lock"); + check(d.may_noclip() && d.may_boost() && d.enforce_tuning, "Dana lost what their role gives beside the locked tool"); + check(!c.may_noclip() && b.permissions && !(*b.permissions & role_movement), "Players without movement got it"); + command("noclip on"); + command("nobail on"); + + // ---- Players with the same locks share a roster; a one-player announcement still works ---- + check(command("lock Dana nobail,boosts") == "Locked for Dana: boosts.", "lock Dana boosts"); + check(command("unlock Dana tuning") == "Unlocked for Dana: tuning.", "unlock Dana tuning"); + check(logged("[lock] Dana (76561198000000004): tuning unlocked by console"), "The unlock was not logged"); + // Bailey and Dana: nobail, boosts. Dana's role would give them back; the roster folds it in. + check(!d.no_bail && !d.boosts && d.noclip && !d.enforce_tuning, "Dana's roster is not Bailey's"); + command("announce-to Casey Your heat is next"); + check(c.announcement && !a.announcement && !b.announcement && !d.announcement, "A one-player announcement reached others"); + check(c.enforce_tuning && free(a), "The announcement changed the locks"); + + // ---- locks lists them ---------------------------------------------------------------------- + reply = command("locks"); + check(reply.starts_with("3 players have locked tools") && reply.find("76561198000000002 Bailey Two: nobail, boosts") != std::string::npos && + reply.find("Casey: tuning") != std::string::npos && reply.find("Dana: nobail, boosts") != std::string::npos, + "locks: " + reply); + + // ---- Wrong use ------------------------------------------------------------------------------ + check(command("lock Casey jetpack").starts_with("lock "), "An unknown tool was accepted"); + check(command("lock Nobody nobail").starts_with("No single connected player"), "An unknown player was accepted"); + check(command("lock Casey").starts_with("lock "), "lock without tools was accepted"); + // An admin in game cannot lock a player of their rank or above; they can lock others, from chat too. + check(command("lock Robin all", admin_id).starts_with("You cannot lock"), "An admin locked their own rank"); + a.say("/lock Casey noclip", now); + run_for(1000000); + check(logged("[lock] Casey (76561198000000003): noclip locked by Robin"), "An admin's lock from chat was not logged by their name"); + check(!c.noclip && has(a.chat, "Locked for Casey: noclip."), "An admin's lock from chat did not apply"); + + // ---- unlock gives back exactly what everyone has ----------------------------------------- + reply = command("unlock Bailey Two"); + check(reply == "Unlocked for Bailey Two: nobail, boosts.", "unlock: " + reply); + check(free(b) && b.permissions && !(*b.permissions & role_movement), "Bailey's roster is not the server's own after unlock"); + check(has(b.chat, "The server unlocked for you: nobail, boosts."), "Bailey was not told of the unlock"); + command("unlock Dana all"); + check(free(d) && d.permissions && (*d.permissions & role_movement), "Dana did not get their roster and movement back"); + check(command("unlock Dana") == "Dana has none of noclip, nobail, boosts, tuning locked.", "unlock with nothing locked"); + + // ---- Locks end when the player leaves ------------------------------------------------------ + c.leave(); + run_for(2000000); + check(logged("[lock] Casey (76561198000000003): noclip, tuning unlocked (left the server)"), "Leaving did not end the locks"); + games.erase(c_id); + auto &c2 = add_game(c_id, "Casey"); + c2.hello(now); + run_for(3000000); + check(c2.joined && free(c2), "Locks came back when the player joined again"); + check(command("locks") == "0 players have locked tools", "Locks remain after everyone was unlocked"); + + // ---- throwdown_locks: the players of a running throwdown -------------------------------------- + check(command("throwdown-locks").find("(now off)") != std::string::npos, "throwdown-locks is not off by default"); + config.activity_log = false; // locks follow throwdowns without the activity log + config.announce_throwdowns = false; + reply = command("throwdown-locks nobail,tuning"); + check(reply == "The players of a throwdown have nobail, tuning locked while it runs.", "throwdown-locks: " + reply); + ThrowdownMessage offer; + offer.kind = ThrowdownMessage::Kind::offer; + offer.leader = b_id; + offer.id = 7; + offer.series = "ThrowdownSkate"; + offer.placement = {1, 2, 3, 4}; + b.throwdown(offer, now); + run_for(500000); + auto join = offer; + join.kind = ThrowdownMessage::Kind::join; + join.series.clear(); + join.placement.clear(); + d.throwdown(join, now); + run_for(500000); + check(free(b) && free(d), "A queue locked its players before it started"); + auto start = join; + start.kind = ThrowdownMessage::Kind::start; + start.order = {b_id, d_id}; + b.throwdown(start, now); + run_for(500000); + check(logged("[lock] Bailey Two (76561198000000002): nobail, tuning locked by throwdown") && + logged("[lock] Dana (76561198000000004): nobail, tuning locked by throwdown"), + "The throwdown's players were not locked"); + check(!b.no_bail && b.enforce_tuning && b.boosts && !d.may_no_bail() && d.enforce_tuning && d.may_boost(), + "The throwdown's players' rosters do not have its locks"); + check(has(b.chat, "Locked for you while your throwdown runs: nobail, tuning."), "Bailey was not told of the throwdown's locks"); + check(free(a) && free(c2), "Players outside the throwdown were locked"); + reply = command("locks"); + check(reply.find("Bailey Two: (throwdown: nobail, tuning)") != std::string::npos, "locks does not show a throwdown's: " + reply); + // An admin's lock outlasts the throwdown; quitting ends the throwdown's. + command("lock Dana boosts"); + auto quit = join; + quit.kind = ThrowdownMessage::Kind::leave; + d.throwdown(quit, now); + run_for(500000); + check(logged("[lock] Dana (76561198000000004): nobail, tuning unlocked by throwdown"), "Quitting the throwdown did not unlock it"); + check(d.no_bail && !d.enforce_tuning && !d.boosts && has(d.chat, "Your throwdown is over, unlocked: nobail, tuning."), + "Dana's roster after quitting: the throwdown's gone, the admin's kept"); + check(!b.no_bail && b.enforce_tuning, "Another player's quitting ended Bailey's throwdown"); + // Gone quiet: a S.K.A.T.E. sends nothing more for 90 s once it has finished. + run_for(95000000); + check(logged("[lock] Bailey Two (76561198000000002): nobail, tuning unlocked by throwdown") && free(b), + "The throwdown's end did not unlock its players"); + command("unlock Dana"); + // Off: nothing is locked for a throwdown. + command("throwdown-locks off"); + offer.id = 8; + b.throwdown(offer, now); + start.id = 8; + b.throwdown(start, now); + run_for(500000); + check(free(b) && free(d), "throwdown-locks off still locked a throwdown's players"); + + for (auto *g : {&a, &b, &d}) + check(!wire().closed.contains(g->id), g->name + " was disconnected: " + (wire().closed.contains(g->id) ? wire().closed[g->id] : std::string{})); + return failures; +} +} // namespace + +int main() { + try { + const auto result = run(); + if (!result) std::printf("server lock tests passed\n"); + return result ? 1 : 0; + } catch (const std::exception &e) { + std::fprintf(stderr, "FAIL: %s\n", e.what()); + return 1; + } +} diff --git a/Server/server_activity.cpp b/Server/server_activity.cpp index b90e17a5..1297da82 100644 --- a/Server/server_activity.cpp +++ b/Server/server_activity.cpp @@ -201,6 +201,15 @@ void ActivityLog::tick(std::uint64_t now) { } } +std::set ActivityLog::playing() const { + std::set players; + for (const auto &[key, t] : throwdowns_) + if (t.started) + for (const auto player : t.players) + if (!t.quit.contains(player)) players.insert(player); + return players; +} + void ActivityLog::left(std::uint64_t player) { name(player); // keep the name for results after they left for (auto it = throwdowns_.begin(); it != throwdowns_.end();) { diff --git a/Server/server_activity.h b/Server/server_activity.h index 2134e16a..91670fae 100644 --- a/Server/server_activity.h +++ b/Server/server_activity.h @@ -35,6 +35,8 @@ class ActivityLog { void clear() { throwdowns_.clear(); } // Ends throwdowns that went quiet: a finished one sends nothing more. void tick(std::uint64_t now); + // The players of the throwdowns running now, but those who quit theirs. + std::set playing() const; private: struct Throwdown { diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 971bc9b2..e2070929 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -32,6 +32,7 @@ constexpr std::string_view help_text = "park-mod config add | | [| ] | park-mod config remove \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick |off | speed-check off|warn|kick | board-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ] | words [reload | global on|off]\n" + "lock | unlock [|all] | locks | throwdown-locks [|off]\n" "reserved [slots | add|remove ] | roles [reload] | role |none | update | quit"; } // namespace @@ -489,7 +490,8 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { const auto value = on_off(argument); if (!value) return std::string("activity-log on|off (now ") + (config_.activity_log ? "on" : "off") + ")"; config_.activity_log = *value; - if (!*value) activity_.clear(); + if (!*value && !config_.throwdown_locks) activity_.clear(); // throwdown_locks still follows them + update_throwdown_locks(); return changed(*value ? "Player activity (throwdowns, objects, loading) is logged." : "Player activity is no longer logged."); } @@ -897,8 +899,95 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(role ? label + " is now " + role->name + "." : label + (config_.roles.default_role.empty() ? " no longer has a role." : " is " + config_.roles.default_role + " again.")); } + if (name == "lock" || name == "unlock") { + // lock ; unlock []. The tools are the + // last word, so a name may have spaces in it. + const bool lock = name == "lock"; + auto who = trim(argument); + std::optional tools = lock ? std::nullopt : std::optional(lock_all); + if (const auto space = who.find_last_of(" \t"); space != std::string_view::npos) + if (const auto named = parse_lock_tools(who.substr(space + 1))) { + tools = named; + who = trim(who.substr(0, space)); + } + if (who.empty() || !tools) + return lock ? "lock (tools: noclip, nobail, boosts, tuning)" + : "unlock []"; + auto *guest = target(who); + if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(who) + "\"."; + if (!outranks(guest->member.id)) return std::string("You cannot ") + (lock ? "lock" : "unlock") + " tools of a player of your rank or above."; + const auto *by = console ? nullptr : find(admin); + const auto label = guest_name(*guest); + const auto done = change_locks(*guest, *tools, lock, false, console ? std::string("console") : by ? guest_name(*by) : std::to_string(admin)); + if (!done) return label + (lock ? " already has " : " has none of ") + lock_tools_text(*tools) + " locked."; + return (lock ? "Locked for " : "Unlocked for ") + label + ": " + lock_tools_text(done) + "."; + } + if (name == "locks") { + std::string text; + unsigned count{}; + for (const auto &[id, locks] : locks_) { + const auto *guest = find(id); + if (!locks.all() || !guest) continue; + ++count; + text += "\n " + std::to_string(id) + " " + guest_name(*guest) + ": " + + (locks.manual ? lock_tools_text(locks.manual) : std::string{}) + + (locks.automatic ? std::string(locks.manual ? " " : "") + "(throwdown: " + lock_tools_text(locks.automatic) + ")" : std::string{}); + } + return std::to_string(count) + (count == 1 ? " player has" : " players have") + " locked tools" + + (config_.throwdown_locks ? " (throwdown-locks: " + lock_tools_text(config_.throwdown_locks) + ")" : std::string{}) + text; + } + if (name == "throwdown-locks") { + if (argument.empty()) + return "throwdown-locks |off (now " + (config_.throwdown_locks ? lock_tools_text(config_.throwdown_locks) : std::string("off")) + ")"; + const auto tools = lower(argument) == "off" ? std::optional(0) : parse_lock_tools(argument); + if (!tools) return "throwdown-locks |off (tools: noclip, nobail, boosts, tuning)"; + // The players of a running throwdown get the new set at once. + for (const auto id : std::exchange(in_throwdown_, {})) + if (auto *guest = find(id)) change_locks(*guest, lock_all, false, true, "throwdown"); + config_.throwdown_locks = *tools; + update_throwdown_locks(); + return changed(*tools ? "The players of a throwdown have " + lock_tools_text(*tools) + " locked while it runs." + : "Throwdowns lock nothing."); + } return "Unknown command \"" + std::string(action) + "\". Type help."; } +std::uint8_t Host::change_locks(Guest &guest, std::uint8_t tools, bool lock, bool automatic, const std::string &source) { + const auto id = guest.member.id; + auto &locks = locks_[id]; + const auto before = locks.all(); + if (lock) (automatic ? locks.automatic : locks.manual) |= tools; + else { + // The throwdown ending takes back its own; an admin's unlock takes back both. + locks.automatic &= static_cast(~tools); + if (!automatic) locks.manual &= static_cast(~tools); + } + const auto after = locks.all(); + if (!after) locks_.erase(id); + const auto changed = static_cast(before ^ after); + if (!changed) return 0; + const auto list = lock_tools_text(changed); + log_("[lock] " + guest_name(guest) + " (" + std::to_string(id) + "): " + list + (lock ? " locked by " : " unlocked by ") + source); + roster_dirty_ = true; + if (changed & lock_movement) guest.permissions_sent = 0; // with or without "movement" (send_permissions) + if (guest.handshaken) + send_chat(lock ? (automatic ? "Locked for you while your throwdown runs: " : "The server locked for you: ") + list + "." + : (automatic ? "Your throwdown is over, unlocked: " : "The server unlocked for you: ") + list + ".", &guest); + return changed; +} +void Host::update_throwdown_locks() { + if (!config_.throwdown_locks && in_throwdown_.empty()) return; + std::set playing; + if (config_.throwdown_locks) + for (const auto id : activity_.playing()) + if (const auto *guest = find(id); guest && guest->handshaken) playing.insert(id); + for (auto it = in_throwdown_.begin(); it != in_throwdown_.end();) { + if (playing.contains(*it)) { ++it; continue; } + if (auto *guest = find(*it)) change_locks(*guest, lock_all, false, true, "throwdown"); + it = in_throwdown_.erase(it); + } + for (const auto id : playing) + if (in_throwdown_.insert(id).second) change_locks(*find(id), config_.throwdown_locks, true, true, "throwdown"); +} unsigned Host::teleport_ring(const std::vector &movers, const std::array &at, float radius) { unsigned sent{}; for (std::size_t i = 0; i < movers.size(); ++i) { diff --git a/Server/server_config.cpp b/Server/server_config.cpp index da9b413b..564304e1 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -128,6 +128,10 @@ Layout layout(const ServerConfig &c) { players.set("allow_object_scaling", c.object_scaling); players.set("sync_effects", c.sync_effects); players.set("announce_throwdowns", c.announce_throwdowns); + auto locks = Json::array(); + for (const auto bit : {lock_noclip, lock_nobail, lock_boosts, lock_tuning}) + if (c.throwdown_locks & bit) locks.push_back(lock_tools_text(bit)); + players.set("throwdown_locks", std::move(locks)); auto &anti_cheat = root.section("anti_cheat"); anti_cheat.set("speed_hack", c.speed_check); @@ -373,6 +377,10 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_array()) + for (const auto &value : *locks) + if (value.is_string()) + if (const auto tools = parse_lock_tools(value.string())) c.throwdown_locks |= *tools; c.speed_check = get("anti_cheat", "speed_hack", c.speed_check, {"speed_check"}); if (c.speed_check != "off" && c.speed_check != "warn" && c.speed_check != "kick") c.speed_check = "warn"; @@ -685,6 +693,34 @@ std::optional parse_scoring(std::string_view text) { if (!value) return std::nullopt; return value; } +std::optional parse_lock_tools(std::string_view text) { + std::uint8_t tools{}; + for (bool more = true; more;) { + const auto comma = text.find(','); + more = comma != std::string_view::npos; + auto name = text.substr(0, comma); + text = more ? text.substr(comma + 1) : std::string_view{}; + while (!name.empty() && std::isspace(static_cast(name.front()))) name.remove_prefix(1); + while (!name.empty() && std::isspace(static_cast(name.back()))) name.remove_suffix(1); + std::string lower(name); + for (auto &c : lower) c = static_cast(std::tolower(static_cast(c))); + if (lower == "noclip" || lower == "teleport") tools |= lock_noclip; + else if (lower == "nobail") tools |= lock_nobail; + else if (lower == "boosts") tools |= lock_boosts; + else if (lower == "tuning") tools |= lock_tuning; + else if (lower == "all") tools |= lock_all; + else return std::nullopt; + } + if (!tools) return std::nullopt; + return tools; +} +std::string lock_tools_text(std::uint8_t tools) { + std::string text; + for (const auto &[bit, name] : {std::pair{lock_noclip, "noclip"}, std::pair{lock_nobail, "nobail"}, + std::pair{lock_boosts, "boosts"}, std::pair{lock_tuning, "tuning"}}) + if (tools & bit) text += (text.empty() ? "" : ", ") + std::string(name); + return text.empty() ? "none" : text; +} std::size_t extra_slots(const ServerConfig &config) noexcept { // (A player with such a role who is also listed has one slot, not two.) std::size_t slots = config.reserved.size(); diff --git a/Server/server_config.h b/Server/server_config.h index 8dd45551..d3728b7a 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -84,6 +84,11 @@ inline constexpr unsigned max_spawned_parks = 16; // ReSkateServer.json. Every setting an admin or the console changes is saved // back, so a restart keeps it. inline constexpr unsigned dedicated_tps = 20; +// The tools the server can take away from single players (lock, throwdown_locks): noclip with +// teleporting, No Bail, the boosts, and their own physics tuning. +inline constexpr std::uint8_t lock_noclip = 1, lock_nobail = 2, lock_boosts = 4, lock_tuning = 8; +inline constexpr std::uint8_t lock_movement = lock_noclip | lock_nobail | lock_boosts; // what the role's "movement" gives +inline constexpr std::uint8_t lock_all = lock_movement | lock_tuning; struct ServerConfig { std::filesystem::path file; std::string name = "ReSkate server"; @@ -147,6 +152,8 @@ struct ServerConfig { bool global_bans = true; // turn away players the ReSkate team has banned (global_bans.h) bool activity_log = true; // console lines for throwdowns, objects and loading bool announce_throwdowns = true; // tell everyone in chat when a throwdown drop is placed + // The tools locked for the players of a throwdown while it runs (lock_* bits); 0: none. + std::uint8_t throwdown_locks{}; // Players form parties (/party, the game's Social menu): 2-8 players each (the game's Party // panel has eight rows). Off, nobody can be in one. bool parties = true; @@ -255,6 +262,11 @@ bool valid_park_key(std::string_view key) noexcept; // A scoring fingerprint as the config and console write it (16 hex digits), and read back // (nothing for text that is not one, or for 0: the game's own scoring needs no entry). std::string scoring_text(std::uint64_t fingerprint); +// Lock tools by name: "noclip" (or "teleport"), "nobail", "boosts", "tuning", "all", several +// joined by commas. Nothing for an unknown name or an empty list. +std::optional parse_lock_tools(std::string_view text); +// "noclip, nobail, tuning"; "none" for 0. +std::string lock_tools_text(std::uint8_t tools); std::optional parse_scoring(std::string_view text); // Maps. The game always has its root level (DingoLevel_Root) loaded, and a map diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 3723bf84..8436cc06 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -112,6 +112,8 @@ void Host::send_permissions(Guest &guest) { p.role_all_commands = role->granted_all; p.role_commands = role->granted_commands; p.role_permissions = role->granted; + // A lock on noclip, No Bail or the boosts is not lifted by the role (their roster has the rest). + if (locks_of(guest.member.id) & lock_movement) p.role_permissions &= ~multiplayer::role_movement; } if (send_packet(guest, p, true, false)) guest.permissions_sent = roles_revision_; } @@ -500,7 +502,8 @@ void Host::flush_poses() { } // A player said which messages of poses they read in full: the poses in those are ones they hold. void Host::pose_ack(Guest &g, const pose_batch::Ack &ack) { g.pose_sender.ack(ack); } -void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint64_t except) { +void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint64_t except, const std::set *only, + const std::set *skip) { // A pose is rounded once and kept (keep_pose); each recipient is then sent its differences // from a pose of this player they hold, several players to a message (flush_poses). auto *kept = packet.kind == PacketKind::pose ? [&]() -> Guest::KeptPose * { @@ -522,6 +525,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 for (auto &[id, guest] : guests_) { auto &p = *guest; if (!p.handshaken || id == except || (gameplay && !p.world_ready)) continue; + if ((only && !only->contains(id)) || (skip && skip->contains(id))) continue; if (packet.kind == PacketKind::voice) { if (!voice_policy_.accepts(packet.voice)) continue; // Each listener fades voices by their own hearing distance; the server @@ -791,17 +795,44 @@ void Host::send_roster() { if (m.party && std::none_of(p.members.begin(), p.members.end(), [&](const Member &o) { return o.party == m.party && o.party_leader; })) m.party_leader = true; // the first listed member of a party missing its leader - // An announcement for one player: everyone else's roster goes without it. - auto *only = announcement_for_ ? find(announcement_for_) : nullptr; - if (only && !only->handshaken) only = nullptr; - if (announcement_for_) { - const auto shown = p.announcement; - p.announcement = {}; - broadcast(p, true, false, announcement_for_); - p.announcement = shown; - if (only) send_packet(*only, p, true, false); - } else { - broadcast(p, true, false); + // An announcement for one player: everyone else's roster goes without it. And a player with + // locked tools is told those are off for them. Everyone else gets the same roster; players + // whose roster differs in the same way share one. + struct Variant { + bool noclip{}, no_bail{}, boosts{}, enforce_tuning{}, announcement{}; + auto operator<=>(const Variant &) const = default; + }; + const Variant common{config_.noclip, config_.no_bail, config_.boosts, config_.enforce_tuning, !announcement_for_}; + std::map> variants; + std::set others; + for (const auto &[id, guest] : guests_) { + if (!guest->handshaken) continue; + auto variant = common; + variant.announcement = !announcement_for_ || id == announcement_for_; + if (const auto locks = locks_of(id)) { + // Without "movement" (send_permissions), the tools their role gives are said here instead. + const bool movement = (locks & lock_movement) && has(id, multiplayer::role_movement); + if (locks & lock_movement) { + variant.noclip = !(locks & lock_noclip) && (config_.noclip || movement); + variant.no_bail = !(locks & lock_nobail) && (config_.no_bail || movement); + variant.boosts = !(locks & lock_boosts) && (config_.boosts || movement); + } + variant.enforce_tuning = config_.enforce_tuning || (locks & lock_tuning); + } + if (variant == common) continue; + variants[variant].insert(id); + others.insert(id); + } + const auto shown = p.announcement; + if (announcement_for_) p.announcement = {}; + broadcast(p, true, false, 0, nullptr, others.empty() ? nullptr : &others); + for (const auto &[variant, players] : variants) { + p.guest_noclip = variant.noclip; + p.guest_no_bail = variant.no_bail; + p.guest_boosts = variant.boosts; + p.enforce_tuning = variant.enforce_tuning; + p.announcement = variant.announcement ? shown : multiplayer::ServerAnnouncement{}; + broadcast(p, true, false, 0, &players); } roster_dirty_ = false; last_roster_ = now_; @@ -914,6 +945,7 @@ void Host::change_map(std::string_view map) { } if (++world_ == 0) throw std::runtime_error("Map transition counter exhausted."); activity_.clear(); // throwdowns end with the world + update_throwdown_locks(); config_.map = map_setting(map); map_ = map_hash(map_destination(config_.map)); map_since_ = now_; @@ -931,6 +963,13 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string & const auto found = guests_.find(id); if (found == guests_.end()) return; const auto name = guest_name(*found->second); + // Locks end with the visit. + in_throwdown_.erase(id); + if (const auto locks = locks_.find(id); locks != locks_.end()) { + if (locks->second.all()) + log_("[lock] " + name + " (" + std::to_string(id) + "): " + lock_tools_text(locks->second.all()) + " unlocked (left the server)"); + locks_.erase(locks); + } if (found->second->handshaken) { roster_dirty_ = true; log_(name + " left (" + reason + ")" + (detail.empty() ? std::string{} : " [" + detail + "]")); @@ -1275,8 +1314,10 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: // attempts and drops go nowhere, whatever the other players' games would do with them. if (link->scoring_flagged) return; link->last_packet = now_; - if (config_.activity_log || config_.announce_throwdowns) + if (config_.activity_log || config_.announce_throwdowns || config_.throwdown_locks) { activity_.throwdown(peer, p.throwdown, link->latest_root ? &link->latest_root->position : nullptr, now_); + update_throwdown_locks(); + } broadcast(p, true, false, p.source); return; } @@ -1861,6 +1902,7 @@ void Host::tick(std::uint64_t now) { tick_parks(); sync_objects(); activity_.tick(now_); + update_throwdown_locks(); if (std::exchange(vote_recount_, false)) check_vote(false); if (vote_ && now_ >= vote_->ends) check_vote(true); // A finished vote has been shown long enough. diff --git a/Server/server_host.h b/Server/server_host.h index 2a8919c9..d8578fc0 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -384,6 +384,25 @@ class Host { return role ? role->rank : -1; } void send_permissions(Guest &); // what their role lets them do + // Tools taken from single players (lock_*, server_config.h): by an admin or the console + // (`manual`), or for a throwdown they play in (`automatic`, config throwdown_locks). In + // memory only: they end when the player leaves. A locked player's roster says the tool is + // off for them, and while noclip, No Bail or the boosts are locked their permissions go + // without "movement", so their role cannot give the tool back. + struct Locks { + std::uint8_t manual{}, automatic{}; + std::uint8_t all() const noexcept { return manual | automatic; } + }; + std::map locks_; + std::set in_throwdown_; // the players throwdown_locks were applied to, while theirs runs + std::uint8_t locks_of(std::uint64_t id) const { + const auto found = locks_.find(id); + return found == locks_.end() ? 0 : found->second.all(); + } + // Locks (`lock`) or unlocks (otherwise) `tools` for a player, as `source` ("console", an + // admin's name, "throwdown"); `automatic`: the throwdown's own. Returns the tools that changed. + std::uint8_t change_locks(Guest &, std::uint8_t tools, bool lock, bool automatic, const std::string &source); + void update_throwdown_locks(); // after the throwdowns or the setting change void send_words(Guest &); // the server's own filtered chat words (server_words.h) void send_next_maps(Guest &); // the maps the vote for the next one will offer void roles_changed(); // after any change to the roles or who has them @@ -394,7 +413,9 @@ class Host { bool send_packet(Guest &, const Packet &, bool reliable, bool fresh, std::span raw = {}, std::span wire = {}); void send_required(Guest &, const std::vector &bytes); - void broadcast(const Packet &, bool reliable, bool fresh, std::uint64_t except = 0); + // `only`: to these players alone; `skip`: not to these. + void broadcast(const Packet &, bool reliable, bool fresh, std::uint64_t except = 0, + const std::set *only = nullptr, const std::set *skip = nullptr); void send_roster(); void send_world_state(); void send_chat(std::string_view text, Guest *only = nullptr); diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 67886f8a..bdabf4be 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -150,7 +150,7 @@ void Host::chat_command(Guest &guest, std::string_view line) { if (config_.parties) text += "/party: your party (invite, accept, leave...; /party help); /p : party chat\n"; if (const auto *role = role_of(guest.member.id); staff(role)) text += role->all_commands - ? "Admins: any server command as /, e.g. /kick, /map, /tpall, /votes, /announce, /announce-to, /msg, /msg-party, /msg-admins\n" + ? "Admins: any server command as /, e.g. /kick, /map, /tpall, /votes, /announce, /announce-to, /msg, /msg-party, /msg-admins, /lock, /unlock, /locks\n" : "Your role (" + role->name + "): these server commands as /: " + joined(role->commands, ", ") + "\n"; const std::string whisper = "/w : send a private message"; return reply(guest, (text.empty() ? "This server has no player votes. Type /tp to teleport.\n" : text) + whisper);