From dd8e6104829d16b81922edb5f35f4103f9c73296 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Sat, 24 Oct 2026 12:26:31 +0200 Subject: [PATCH] Plugins: a dedicated server's plugins hide a player from others, or mute them Hide and Seek, tag, spectating and ghost runs need one player to be gone for some others, which no plugin could do. reskate.server.hide(target, viewers, {skater, name, voice, sound}) and reskate.server.unhide(target, viewers?) do it per viewer; viewers is a player, a list of players or "all" (everyone on now but the target). Wholly (the default) the target leaves those viewers' rosters, so their games take the skater, its name tag and its objects away as for a player who left, and the server sends them nothing of the target: poses, sound, voice, outfit, effects and objects. No protocol change: the roster per viewer is the one the cards and polls use, and the server's introduce already holds a player back until shown. Shown again, the target is introduced anew (outfit, then whole poses, then all its objects). Without the skater (skater = false), only the voice and/or the sound is held back and the player stays. Name and skater go together: a game has no skater without its player, and hiding one without the other would need a protocol change. Refused while the target shares a party or a throwdown with one of the viewers, whose game would lose a party member or a player mid-way; while hidden, the two cannot come into one party. A plugin that stops shows everyone it hid; a player who leaves is dropped from every list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML --- Extension/Plugins/Test/plugin_tests.cpp | 57 +++++++++ Extension/Plugins/plugin_api_world.cpp | 42 ++++++- Extension/Plugins/plugin_game.h | 16 +++ Server/Test/server_plugin_tests.cpp | 150 ++++++++++++++++++++++-- Server/server_activity.cpp | 11 ++ Server/server_activity.h | 3 + Server/server_host.cpp | 103 +++++++++++++++- Server/server_host.h | 23 ++++ Server/server_party.cpp | 12 ++ Server/server_plugins.cpp | 12 +- 10 files changed, 416 insertions(+), 13 deletions(-) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 5edadca5..be2901b2 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -560,6 +560,15 @@ struct FakeGame final : Game { calls.push_back(std::format("park remove {} {}", plugin, name)); return {}; } + std::string server_hide(std::string_view plugin, std::uint64_t target, const std::vector& viewers, const Hiding& what) override { + calls.push_back(std::format("hide {} {} from {} skater {} voice {} sound {}", plugin, target, players_text(viewers), what.skater, what.voice, + what.sound)); + return target == 9 ? "that player is not on the server" : std::string{}; + } + std::string server_unhide(std::string_view plugin, std::uint64_t target, const std::vector& viewers) override { + calls.push_back(std::format("unhide {} {} from {}", plugin, target, players_text(viewers))); + return {}; + } static std::string players_text(const std::vector& players) { std::string text; for (const auto id : players) text += (text.empty() ? "" : ",") + std::to_string(id); @@ -2107,6 +2116,53 @@ void plugins_server_copies_are_the_authority() { // A dedicated server's park mods, for a server copy with server.parks: listed, spawned and removed // as the server's park-mod does. Not for a copy without the permission, nor for a player's copy. +// One player hidden from others, for a server copy with "server": wholly (skater, name, voice, +// sound) or only their voice or sound, from a player, a list of them or "all", and shown again. +// Not for a copy without the permission, nor for a player's copy. +void plugins_server_copies_hide_players() { + TestEnvironment server, player, blind; + const std::string main = R"( + reskate.console.command("hide", "x", function() + assert(reskate.server.hide("7", "all")) + assert(reskate.server.hide({id = "7"}, {"8", {id = "5"}})) + assert(reskate.server.hide("7", "8", {skater = false, voice = true, sound = false})) + assert(reskate.server.hide("7", "8", {skater = false, sound = true})) + assert(reskate.server.hide("7", "8", {skater = true, name = true})) + local ok, why = reskate.server.hide("9", "8") + assert(not ok and why == "that player is not on the server", tostring(why)) + assert(reskate.server.unhide("7", "8")) + assert(reskate.server.unhide("7")) + assert(not pcall(reskate.server.hide, "7"), "who it is hidden from") + assert(not pcall(reskate.server.hide, "7", {}), "at least one viewer") + assert(not pcall(reskate.server.hide, "7", "8", {skater = false, voice = false, sound = false}), "it hides something") + assert(not pcall(reskate.server.hide, "7", "8", {skater = true, voice = false}), "a hidden skater's voice goes too") + assert(not pcall(reskate.server.hide, "7", "8", {skater = false, name = true}), "the name goes with the skater") + assert(not pcall(reskate.server.hide, "7", "8", {colour = true}), "only skater, name, voice and sound") + assert(not pcall(reskate.server.hide, "7", "8", {skater = "yes"}), "true or false") + assert(not pcall(reskate.server.unhide, "nobody"), "a player") + reskate.log("hide done") + end) + )"; + const auto hider = [&](const char* id, const char* permissions, const char* side) { + return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server); + server_host.apply({hider("hide-s", R"("console", "server")", "server")}); + player_host.apply({hider("hide-p", R"("console", "server")", "client")}); + blind_host.apply({hider("hide-b", R"("console")", "server")}); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("hide"); + check(server.logged("[hide-s] hide done"), "A server copy could not hide players\n" + server.all()); + for (const auto* call : {"hide hide-s 7 from all skater true voice true sound true", "hide hide-s 7 from 8,5 skater true voice true sound true", + "hide hide-s 7 from 8 skater false voice true sound false", "hide hide-s 7 from 8 skater false voice true sound true", + "hide hide-s 9 from 8 skater true voice true sound true", "unhide hide-s 7 from 8", "unhide hide-s 7 from all"}) + check(server.fake.called(call), std::string("The server was asked: ") + call + "\n" + server.all()); + check(!server.fake.called("hide hide-s 7 from 8 skater false voice false sound false") && !server.fake.called("unhide hide-s 0"), + "A refused call reached the server"); + check(!player.logged("hide done") && player.all().find("server copy") != std::string::npos, "A player's copy hid players\n" + player.all()); + check(!blind.logged("hide done") && blind.all().find("\"server\"") != std::string::npos, "A plugin without server hid players\n" + blind.all()); + running(server_host, "hide-s", server); +} + void plugins_server_copies_spawn_park_mods() { TestEnvironment server, player, blind; const std::string main = R"( @@ -4052,6 +4108,7 @@ int main() { run(animator_studio_records_and_saves); run(plugins_server_copies_are_the_authority); run(plugins_server_copies_spawn_park_mods); + run(plugins_server_copies_hide_players); run(plugins_server_copies_see_roles); run(plugins_server_copies_show_cards_and_polls); run(plugins_animate_the_skater); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 8c021366..05a15db3 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include // The wider set's game parts of the reskate API: the skater's state, control and save points, the @@ -2140,6 +2141,44 @@ struct WorldApi { if (!(id >= 1 && id <= 4294967295.0) || id != std::floor(id)) luaL_argerror(L, 1, "a poll's id"); return result(L, game(plugin).server_poll_close(plugin.id(), static_cast(id))); } + // ---- hiding a player from others ---------------------------------------------------------------- + // server.hide(target, viewers, {skater, name, voice, sound}?): each true unless said. The name + // goes with the skater (a game has no skater without its player); without them, only the voice + // and/or the sound is held back. + static int server_hide(lua_State* L) { + auto& plugin = authority(L, "reskate.server.hide"); + const auto target = player_of(L, 1); + if (lua_isnoneornil(L, 2)) luaL_argerror(L, 2, "who they are hidden from: a player, a list of players, or \"all\""); + const auto viewers = players_of(L, 2); + Game::Hiding what; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + std::optional skater, name, voice, sound; + lua_pushnil(L); + while (lua_next(L, 3)) { + const std::string_view key = lua_type(L, -2) == LUA_TSTRING ? lua_tostring(L, -2) : ""; + auto* field = key == "skater" ? &skater : key == "name" ? &name : key == "voice" ? &voice : key == "sound" ? &sound : nullptr; + if (!field) luaL_argerror(L, 3, "only skater, name, voice and sound"); + if (!lua_isboolean(L, -1)) luaL_argerror(L, 3, "skater, name, voice and sound are true or false"); + *field = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + } + if (skater && name && *skater != *name) luaL_argerror(L, 3, "the name goes with the skater: both true or both false"); + what.skater = skater.value_or(name.value_or(true)); + what.voice = voice.value_or(true); + what.sound = sound.value_or(true); + if (what.skater && (!what.voice || !what.sound)) luaL_argerror(L, 3, "a hidden skater's voice and sound are hidden too"); + if (!what.skater && !what.voice && !what.sound) luaL_argerror(L, 3, "nothing to hide: the skater, the voice or the sound"); + } + return result(L, game(plugin).server_hide(plugin.id(), target, viewers, what)); + } + // server.unhide(target, viewers?): shown again to them (none: to everyone this plugin hid them from). + static int server_unhide(lua_State* L) { + auto& plugin = authority(L, "reskate.server.unhide"); + const auto target = player_of(L, 1); + const auto viewers = lua_isnoneornil(L, 2) ? std::vector{} : players_of(L, 2); + return result(L, game(plugin).server_unhide(plugin.id(), target, viewers)); + } // ---- server.parks: a dedicated server's park mods ----------------------------------------------- // parks(): {name, map, here (fits the map running now), spawned, objects, minutes_left}[]; // park_spawn(name, minutes?): for that long (1-1440), or until removed; park_remove(name). @@ -2892,7 +2931,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}}); add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}, {"ban", server_ban}, {"unban", server_unban}, {"parks", server_parks}, {"park_spawn", server_park_spawn}, - {"park_remove", server_park_remove}, {"card", server_card}, {"poll", server_poll}, {"poll_close", server_poll_close}}); + {"park_remove", server_park_remove}, {"card", server_card}, {"poll", server_poll}, {"poll_close", server_poll_close}, + {"hide", server_hide}, {"unhide", server_unhide}}); add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting}, {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}}); add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index b419df59..f2076e0e 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -643,6 +643,22 @@ public: std::string text, why; }; virtual std::vector take_poll_news() { return {}; } + // One player hidden from others (a dedicated server's only): `skater` takes them out of those + // players' games altogether (skater, name, objects, voice and sound, as if they had left); + // without it, only their voice and/or sound is held back. `viewers` empty is everyone on now + // but the target. Refused while the target shares a party or a throwdown with one of them. + // Showing them again (`viewers` empty: everyone the plugin hid them from) sends their outfit, + // poses and objects anew. + struct Hiding { + bool skater = true, voice = true, sound = true; + }; + virtual std::string server_hide(std::string_view /*plugin*/, std::uint64_t /*target*/, const std::vector& /*viewers*/, + const Hiding&) { + return unavailable; + } + virtual std::string server_unhide(std::string_view /*plugin*/, std::uint64_t /*target*/, const std::vector& /*viewers*/) { + return unavailable; + } // ---- skater.animation: clips on the local skater's body (Extension/Skater/skater_animation.h) ------ // One plugin plays at a time; none while a throwdown runs. A replacement plays when the game does // `trick` ("kickflip") with that pop ("" any, "low", "mid", "high"); a null clip takes it away. diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 1bea7beb..419f7b22 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -8,6 +8,7 @@ #include "Extension/Multiplayer/Net/pose_codec.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Extension/Multiplayer/Steam/steam_transport.h" +#include "Extension/Throwdowns/throwdown_wire.h" #include "Engine/Game/Build/supported_build.h" #include "Engine/Game/World/world_names.h" @@ -143,16 +144,41 @@ struct Player { p.text = text; wire().inbound.push_back({id, encode_wire(p), now}); } - // The latest roster the server sent this player (and forgets what else it sent). - std::optional roster() { - std::optional latest; - for (const auto &bytes : std::exchange(wire().sent[id], {})) - if (auto p = decode_wire(bytes); p && p->kind == PacketKind::roster) latest = std::move(p); - return latest; + void throwdown(std::uint64_t now, const ThrowdownMessage &message) { + auto p = header(PacketKind::throwdown, now); + p.throwdown = encode_throwdown(message); + wire().inbound.push_back({id, encode_wire(p), now}); } + // A moment of voice chat, heard everywhere (no hearing distance). + void speak(std::uint64_t now) { + auto p = header(PacketKind::voice, now); + p.voice.distance = 0.f; + p.voice.bytes = {1, 2, 3, 4}; + wire().inbound.push_back({id, encode_wire(p), now}); + } + // What the server sent this player since the last look: the latest roster, how many skaters' + // poses (entries of its pose messages) and how much voice. Acks for their own poses are taken. + struct Seen { + std::optional roster; + std::size_t poses{}, voices{}; + }; + Seen seen; + void read_sent() { + for (const auto &bytes : std::exchange(wire().sent[id], {})) { + if (const auto ack = pose_batch::Ack::read(bytes)) upload.ack(*ack); + else if (const auto batch = pose_batch::read(bytes)) seen.poses += batch->entries.size(); + else if (auto p = decode_wire(bytes); p && p->kind == PacketKind::roster) seen.roster = std::move(p); + else if (p && p->kind == PacketKind::voice) ++seen.voices; + } + } + Seen look() { + read_sent(); + return std::exchange(seen, {}); + } + // The latest roster the server sent this player (and forgets what else it sent). + std::optional roster() { return look().roster; } void pose(std::uint64_t now) { - for (const auto &bytes : std::exchange(wire().sent[id], {})) - if (const auto read = pose_batch::Ack::read(bytes)) upload.ack(*read); + read_sent(); auto p = header(PacketKind::pose, now); p.pose.root.position = at; p.pose.root.rotation = {0.f, std::sin(heading * 0.5f), 0.f, std::cos(heading * 0.5f)}; @@ -359,6 +385,20 @@ int run(const std::filesystem::path &fixtures) { end) )lua"); + // One that hides a player from another, wholly or only their voice and sound, and shows them again. + write(root / "Plugins" / "hider" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server"]})"); + write(root / "Plugins" / "hider" / "plugin" / "main.lua", R"( + reskate.console.command("hider", "hiding, for the test", function(args) + local verb, target, viewer = args[1], args[2], args[3] + local ok, why + if verb == "hide" then ok, why = reskate.server.hide(target, viewer) + elseif verb == "mute" then ok, why = reskate.server.hide(target, viewer, {skater = false}) + elseif verb == "unhide" then ok, why = reskate.server.unhide(target, viewer) end + reskate.console.print(verb .. " " .. tostring(ok or why)) + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -386,7 +426,7 @@ int run(const std::filesystem::path &fixtures) { players[1].at = {-4.f, 7.5f, 12.f}; std::uint64_t now = 1000000000ULL, next_ready = now; - bool posing{}; + bool posing{}, speaking{}; const auto run_for = [&](std::uint64_t microseconds) { for (const auto end = now + microseconds; now < end; now += 50000) { if (now >= next_ready) { @@ -395,6 +435,7 @@ int run(const std::filesystem::path &fixtures) { } if (posing) for (auto &p : players) p.pose(now); + if (speaking && (now / 50000) % 4 == 0) players[0].speak(now); host.tick(now); } }; @@ -548,6 +589,97 @@ int run(const std::filesystem::path &fixtures) { run_for(200000); check(!has(log, "heard 76561198000000002 Player 2: /help"), "One of the server's own commands went to the plugin"); + // Hiding: the first player hidden from the second leaves the second's roster and poses, and + // only theirs; the first still sees the second. Each window is longer than the roster's 2 s. + const auto in_roster = [](const std::optional &roster, std::uint64_t id) { + return roster && std::any_of(roster->members.begin(), roster->members.end(), [&](const Member &m) { return m.id == id; }); + }; + const auto settle = [&] { + run_for(300000); // what was on its way before + for (auto &p : players) p.look(); + run_for(2500000); + }; + posing = speaking = true; + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && viewer.voices > 0 && in_roster(viewer.roster, players[0].id), + "The test needs the second player to see and hear the first"); + } + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide true"), "The plugin could not hide a player"); + settle(); + { + auto viewer = players[1].look(), target = players[0].look(); + check(viewer.poses == 0 && viewer.voices == 0, "The viewer was still sent the hidden player's poses or voice"); + check(viewer.roster && !in_roster(viewer.roster, players[0].id) && in_roster(viewer.roster, players[1].id), + "The hidden player was still in the viewer's roster"); + check(target.poses > 0 && in_roster(target.roster, players[1].id), "The hidden player stopped seeing the viewer"); + } + // Shown again: back in the roster, and their poses follow. + plugin_command("hider unhide " + first_id + " " + second_id); + check(has(log, "unhide true"), "The plugin could not show a player again"); + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && viewer.voices > 0 && in_roster(viewer.roster, players[0].id), "A player shown again did not come back"); + } + // Only their voice and sound: they stay in the roster and their poses go on. + plugin_command("hider mute " + first_id + " " + second_id); + check(has(log, "mute true"), "The plugin could not mute a player"); + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && in_roster(viewer.roster, players[0].id), "A muted player was hidden"); + check(viewer.voices == 0, "A muted player's voice was still sent"); + } + // A plugin that stops shows everyone it hid. + plugin_command("hider hide " + first_id + " " + second_id); + check(host.command("plugin off hider").find("hider") != std::string::npos, "The plugin could not be switched off"); + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && viewer.voices > 0 && in_roster(viewer.roster, players[0].id), "A plugin that stopped left a player hidden"); + } + host.command("plugin on hider"); + run_for(2500000); // it loads again on the next scan + // Not while they share a party, nor a throwdown. + players[0].chat(now, "/party invite Player 2"); + run_for(200000); + players[1].chat(now, "/party accept Player 1"); + run_for(200000); + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide they share a party"), "A player was hidden from someone in their party"); + players[1].chat(now, "/party leave"); + run_for(200000); + ThrowdownMessage offer; + offer.kind = ThrowdownMessage::Kind::offer; + offer.leader = players[0].id; + offer.id = 1; + offer.series = "skate"; + offer.placement = {1, 2, 3, 4}; // opaque to the server + players[0].throwdown(now, offer); + auto join = offer; + join.kind = ThrowdownMessage::Kind::join; + players[1].throwdown(now, join); + run_for(200000); + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide they are in a throwdown together"), "A player was hidden from someone in their throwdown"); + auto close = offer; + close.kind = ThrowdownMessage::Kind::close; + players[0].throwdown(now, close); + run_for(200000); + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide true"), "A player could not be hidden once the party and the throwdown were over"); + // Hidden, they cannot join the viewer's party either. + log.clear(); + players[0].chat(now, "/party invite Player 2"); + run_for(200000); + check(!has(log, "[party] Player 1 invited Player 2"), "A hidden player invited the viewer to their party"); + plugin_command("hider unhide " + first_id); + check(has(log, "unhide true"), "The plugin could not show a player to everyone"); + posing = speaking = false; + // The map and the world as the session's, and the maps the server knows. plugin_command("talker world"); check(has(log, "world 1 map " + host.map_name()), "session.world() is not the server's map and world"); diff --git a/Server/server_activity.cpp b/Server/server_activity.cpp index 1297da82..7ca64a0a 100644 --- a/Server/server_activity.cpp +++ b/Server/server_activity.cpp @@ -210,6 +210,17 @@ std::set ActivityLog::playing() const { return players; } +bool ActivityLog::together(std::uint64_t a, std::uint64_t b) const { + for (const auto &[key, t] : throwdowns_) { + const auto in = [&](std::uint64_t player) { + return !t.quit.contains(player) && (key.first == player || std::ranges::find(t.queue, player) != t.queue.end() || + std::ranges::find(t.players, player) != t.players.end()); + }; + if (in(a) && in(b)) return true; + } + return false; +} + 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 91670fae..69785407 100644 --- a/Server/server_activity.h +++ b/Server/server_activity.h @@ -37,6 +37,9 @@ class ActivityLog { void tick(std::uint64_t now); // The players of the throwdowns running now, but those who quit theirs. std::set playing() const; + // Whether two players are in one throwdown now: its leader, its queue or its players, but + // those who quit. + bool together(std::uint64_t a, std::uint64_t b) const; private: struct Throwdown { diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 7ff9a932..396086f8 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -207,6 +207,89 @@ void Host::send_plugin_message(std::uint64_t to, const std::vector if (auto *guest = find(to); guest && guest->handshaken) send_packet(*guest, p, true, false); } +// ---- Players the server's plugins hide from others ------------------------------------------------ +std::string Host::plugin_hide(std::string_view plugin, std::uint64_t target, const std::vector &viewers, bool skater, bool voice, + bool sound) { + const auto *hidden = find(target); + if (!hidden || !hidden->handshaken) return "that player is not on the server"; + std::vector from; + for (auto &[id, guest] : guests_) + if (guest->handshaken && id != target && (viewers.empty() || std::find(viewers.begin(), viewers.end(), id) != viewers.end())) + from.push_back(guest.get()); + if (from.empty()) return viewers.empty() ? "nobody else is on the server" : "none of those players is on the server"; + // Their games would take a party member or a throwdown's player away mid-way. + const auto party = parties_.party_of(target); + for (const auto *viewer : from) { + if (party && parties_.party_of(viewer->member.id) == party) return "they share a party"; + if (activity_.together(target, viewer->member.id)) return "they are in a throwdown together"; + } + std::vector names; + for (auto *viewer : from) { + auto &entry = hidden_[viewer->member.id][target]; + // Wholly from now: not introduced again until shown (introduce), and sent afresh then. + if (skater && !entry.skater) { + viewer->unmet.insert(target); + viewer->pose_sender.forget(target); + viewer->sound_sender.forget(target); + viewer->object_delivery.sent.erase(target); + if (viewer->object_delivery.source == target) viewer->object_delivery.chunks.clear(); + } + entry = {std::string(plugin), skater, skater || voice, skater || sound}; + names.push_back(guest_name(*viewer)); + } + log_("[hide] " + guest_name(*hidden) + (skater ? " hidden from " : std::string(voice && sound ? " muted for " : voice ? " voice muted for " : " sound muted for ")) + + (viewers.empty() ? std::string("everyone") : std::to_string(names.size()) + " player(s)") + " by plugin " + std::string(plugin)); + roster_dirty_ = true; + return {}; +} + +std::string Host::plugin_unhide(std::string_view plugin, std::uint64_t target, const std::vector &viewers) { + bool shown{}; + for (auto viewer = hidden_.begin(); viewer != hidden_.end();) { + if (viewers.empty() || std::find(viewers.begin(), viewers.end(), viewer->first) != viewers.end()) + if (const auto found = viewer->second.find(target); found != viewer->second.end() && found->second.plugin == plugin) { + viewer->second.erase(found); + shown = true; + } + viewer = viewer->second.empty() ? hidden_.erase(viewer) : std::next(viewer); + } + if (!shown) return "the plugin hides that player from none of them"; + const auto *guest = find(target); + log_("[hide] " + (guest ? guest_name(*guest) : std::to_string(target)) + " shown again by plugin " + std::string(plugin)); + roster_dirty_ = true; + return {}; +} + +void Host::plugin_hides_release(std::string_view plugin) { + bool shown{}; + for (auto viewer = hidden_.begin(); viewer != hidden_.end();) { + shown |= std::erase_if(viewer->second, [&](const auto &entry) { return entry.second.plugin == plugin; }) > 0; + viewer = viewer->second.empty() ? hidden_.erase(viewer) : std::next(viewer); + } + if (shown) roster_dirty_ = true; +} + +const Host::Hidden *Host::hidden_from(std::uint64_t viewer, std::uint64_t target) const { + if (hidden_.empty()) return nullptr; + const auto found = hidden_.find(viewer); + if (found == hidden_.end()) return nullptr; + const auto entry = found->second.find(target); + return entry == found->second.end() ? nullptr : &entry->second; +} + +bool Host::hidden_between(std::uint64_t a, std::uint64_t b) const { + const auto *one = hidden_from(a, b), *other = hidden_from(b, a); + return (one && one->skater) || (other && other->skater); +} + +void Host::hidden_left(std::uint64_t player) { + hidden_.erase(player); + for (auto viewer = hidden_.begin(); viewer != hidden_.end();) { + viewer->second.erase(player); + viewer = viewer->second.empty() ? hidden_.erase(viewer) : std::next(viewer); + } +} + std::optional> Host::player_position(std::uint64_t steam_id) const { auto it = guests_.find(steam_id); if (it != guests_.end() && it->second && it->second->latest_root) { @@ -554,6 +637,10 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 auto &p = *guest; if (!p.handshaken || id == except || (gameplay && !p.world_ready)) continue; if ((only && !only->contains(id)) || (skip && skip->contains(id))) continue; + // A player a plugin hides from this one (plugin_hide): nothing of them, or not their voice or sound. + if (const auto *hidden = hidden_from(id, packet.source); + hidden && (hidden->skater ? gameplay : (packet.kind == PacketKind::voice && hidden->voice) || (packet.kind == PacketKind::audio && hidden->sound))) + continue; if (packet.kind == PacketKind::voice) { if (!voice_policy_.accepts(packet.voice)) continue; // Each listener fades voices by their own hearing distance; the server @@ -739,6 +826,8 @@ void Host::introduce() { continue; } ++it; + // Hidden from them by a plugin: introduced once shown again (plugin_unhide). + if (const auto *hidden = hidden_from(id, other->member.id); hidden && hidden->skater) continue; float distance = std::numeric_limits::max(); if (g.latest_root && other->latest_root) { distance = 0; @@ -825,11 +914,13 @@ void Host::send_roster() { 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. A player with // locked tools is told those are off for them, and one with a plugin's card or poll is shown - // it (by its id) over the server's. Everyone else gets the same roster; players whose roster - // differs in the same way share one. + // it (by its id) over the server's; one a plugin hides players from (plugin_hide) is sent a + // roster without them, so their game takes those skaters away. Everyone else gets the same + // roster; players whose roster differs in the same way share one. struct Variant { bool noclip{}, no_bail{}, boosts{}, ollies{}, enforce_tuning{}, announcement{}; std::uint32_t plugin_card{}, plugin_poll{}; + std::vector hidden; // in order auto operator<=>(const Variant &) const = default; }; const Variant common{config_.noclip, config_.no_bail, config_.boosts, true, config_.enforce_tuning, !announcement_for_}; @@ -841,6 +932,9 @@ void Host::send_roster() { variant.announcement = !announcement_for_ || id == announcement_for_; if (const auto card = plugin_cards_.find(id); card != plugin_cards_.end()) variant.plugin_card = card->second.card.id; if (const auto poll = plugin_polls_.find(id); poll != plugin_polls_.end()) variant.plugin_poll = poll->second.id; + if (const auto from = hidden_.find(id); from != hidden_.end()) + for (const auto &[target, what] : from->second) + if (what.skater) variant.hidden.push_back(target); 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); @@ -858,9 +952,12 @@ void Host::send_roster() { } const auto shown = p.announcement; const auto vote = p.vote; + const auto members = p.members; if (announcement_for_) p.announcement = {}; broadcast(p, true, false, 0, nullptr, others.empty() ? nullptr : &others); for (const auto &[variant, players] : variants) { + p.members = members; + std::erase_if(p.members, [&](const Member &m) { return std::binary_search(variant.hidden.begin(), variant.hidden.end(), m.id); }); p.guest_noclip = variant.noclip; p.guest_no_bail = variant.no_bail; p.guest_boosts = variant.boosts; @@ -1079,6 +1176,7 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string & } party_left(id, name); plugin_screens_left(id); + hidden_left(id); // Their vote cooldown stays (tick() drops it once it has run out): leaving and coming // back does not let a player start another vote sooner. // One voter fewer, or the player a kick vote was about. Counted in tick(): a passing kick @@ -1667,6 +1765,7 @@ void Host::sync_objects() { for (std::size_t attempt = 0; attempt < sources.size(); ++attempt) { const auto &source = sources[delivery.cursor++ % sources.size()]; if (source.id == id || !source.state->revision()) continue; + if (const auto *hidden = hidden_from(id, source.id); hidden && hidden->skater) continue; // plugin_hide const auto previous = delivery.sent.find(source.id); const auto since = previous != delivery.sent.end() && previous->second.first == source.epoch ? previous->second.second : 0; diff --git a/Server/server_host.h b/Server/server_host.h index 072e5389..f28b9314 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -148,6 +148,17 @@ class Host { std::string text, why; }; std::vector take_poll_news() { return std::exchange(poll_news_, {}); } + // One player hidden from others for the server's plugins (server_host.cpp). Wholly (`skater`): + // they leave those players' rosters, and nothing of them (poses, sound, voice, outfit, effects, + // objects) is sent to them, so their games take the skater away as for a player who left. + // Otherwise only their voice and/or sound is held back. `viewers` empty is everyone on now but + // the target. Refused while the target shares a party or a throwdown with one of them. Shown + // again (`viewers` empty: everyone this plugin hid them from), they are introduced anew. + // "" when done, else why not. + std::string plugin_hide(std::string_view plugin, std::uint64_t target, const std::vector &viewers, bool skater, bool voice, + bool sound); + std::string plugin_unhide(std::string_view plugin, std::uint64_t target, const std::vector &viewers); + void plugin_hides_release(std::string_view plugin); // a plugin that stopped: everyone it hid is shown again enum class VoteKind { map, kick, time, custom, poll, extend }; @@ -348,6 +359,13 @@ class Host { std::map plugin_cards_; std::map plugin_polls_; std::vector poll_news_; + // The players the server's plugins hide (plugin_hide), by who they are hidden from: each + // target's plugin, and what of them is held back. + struct Hidden { + std::string plugin; + bool skater{}, voice{}, sound{}; + }; + std::map