From 3bfabed70512bbb1ef199c0966f89477f07e6d52 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Tue, 13 Oct 2026 13:33:55 +0200 Subject: [PATCH] Server: tools can be locked for single players, and for a throwdown's players The server can take noclip (with teleporting), No Bail, the boosts and the player's own physics tuning from one player while everyone else keeps what the server allows: for fair throwdowns on a server that allows them for free skating. Server only: a locked player gets a roster of their own with the tool off (players with the same locks share one) and permissions without "movement", so their role cannot give it back. Players without locks get exactly the roster they got before. - lock , unlock [|all], locks: from the console, or an admin in chat. In memory only; locks end when the player leaves. Logged as "[lock] (): nobail, boosts locked by ". - players.throwdown_locks (default []): tools locked for the players of a throwdown from its start until they quit, it ends or they leave (console: throwdown-locks |off). - Tests: Server/Test/server_lock_tests.cpp, and the key in the config tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PreNfxwh21xyPp8EA72cUN --- CMakeLists.txt | 18 ++ Server/README-roles.txt | 9 +- Server/README.txt | 28 +- Server/Test/server_config_tests.cpp | 19 ++ Server/Test/server_lock_tests.cpp | 421 ++++++++++++++++++++++++++++ Server/server_activity.cpp | 9 + Server/server_activity.h | 2 + Server/server_commands.cpp | 91 +++++- Server/server_config.cpp | 36 +++ Server/server_config.h | 12 + Server/server_host.cpp | 68 ++++- Server/server_host.h | 23 +- Server/server_votes.cpp | 2 +- 13 files changed, 720 insertions(+), 18 deletions(-) create mode 100644 Server/Test/server_lock_tests.cpp 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);