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