Merge pull request #353 from moelrobi/plugins/server-visibility

Plugins: server plugins hide a player from others, or mute them
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-24 11:10:21 -05:00
commit cd45a77843
10 files changed
+416 -13

No files matched your search

+57
View File
@@ -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<std::uint64_t>& 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<std::uint64_t>& viewers) override {
calls.push_back(std::format("unhide {} {} from {}", plugin, target, players_text(viewers)));
return {};
}
static std::string players_text(const std::vector<std::uint64_t>& 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);
+41 -1
View File
@@ -8,6 +8,7 @@
#include <charconv>
#include <cmath>
#include <cstring>
#include <optional>
#include <span>
// 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<std::uint32_t>(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<bool> 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<std::uint64_t>{} : 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},
+16
View File
@@ -643,6 +643,22 @@ public:
std::string text, why;
};
virtual std::vector<PollNews> 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<std::uint64_t>& /*viewers*/,
const Hiding&) {
return unavailable;
}
virtual std::string server_unhide(std::string_view /*plugin*/, std::uint64_t /*target*/, const std::vector<std::uint64_t>& /*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.
+141 -9
View File
@@ -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<Packet> roster() {
std::optional<Packet> 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<Packet> 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<Packet> 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<std::string> 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<Packet> &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");
+11
View File
@@ -210,6 +210,17 @@ std::set<std::uint64_t> 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();) {
+3
View File
@@ -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<std::uint64_t> 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 {
+101 -2
View File
@@ -207,6 +207,89 @@ void Host::send_plugin_message(std::uint64_t to, const std::vector<std::uint8_t>
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<std::uint64_t> &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<Guest *> 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<std::string> 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<std::uint64_t> &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<std::array<float, 3>> 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<float>::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<std::uint64_t> 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;
+23
View File
@@ -148,6 +148,17 @@ class Host {
std::string text, why;
};
std::vector<PollNews> 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<std::uint64_t> &viewers, bool skater, bool voice,
bool sound);
std::string plugin_unhide(std::string_view plugin, std::uint64_t target, const std::vector<std::uint64_t> &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<std::uint64_t, PluginCard> plugin_cards_;
std::map<std::uint64_t, PluginPoll> plugin_polls_;
std::vector<PollNews> 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<std::uint64_t, std::map<std::uint64_t, Hidden>> hidden_;
std::uint64_t announced_at_{}; // the last timed announcement, or while nobody is on
std::size_t next_announcement_{};
void active(Guest &guest) {
@@ -590,6 +608,11 @@ class Host {
void tick_plugin_screens();
void plugin_screens_left(std::uint64_t player);
void plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const;
// What of `target` a plugin holds back from `viewer`, or null; whether either of two players is
// wholly hidden from the other; and a player who left, taken out of every list.
const Hidden *hidden_from(std::uint64_t viewer, std::uint64_t target) const;
bool hidden_between(std::uint64_t a, std::uint64_t b) const;
void hidden_left(std::uint64_t player);
void reply(Guest &, std::string_view text, unsigned max_lines = 12);
Guest *match_player(std::string_view text);
// Sends each mover to a ring of `radius` around `at`, 1 m up, so nobody lands inside anyone; how many were sent.
+12
View File
@@ -73,6 +73,18 @@ void Host::party_request(Guest &guest, PartyAction action, std::uint64_t player)
if (!other || !other->handshaken) return reply(guest, "That player is not on the server.");
}
const auto their_name = other ? guest_name(*other) : std::string{};
// Not into one party with a player a plugin hides from them, or from whom they are hidden
// (plugin_hide): their games would have a party member they cannot see.
if (other && (action == PartyAction::invite || action == PartyAction::accept || action == PartyAction::join) && !hidden_.empty()) {
const auto with = [&](std::uint64_t who) {
std::vector<std::uint64_t> group{who};
if (const auto *details = parties_.party(parties_.party_of(who))) group.assign(details->members.begin(), details->members.end());
return group;
};
for (const auto a : with(me))
for (const auto b : with(player))
if (hidden_between(a, b)) return reply(guest, "That party is not open to you right now.");
}
using R = PartyBook::Result;
auto result = R::ok;
switch (action) {
+11 -1
View File
@@ -111,7 +111,17 @@ class ServerGame final : public Game {
for (auto &n : host_.take_poll_news()) news.push_back({std::move(n.plugin), n.id, n.player, n.answer, std::move(n.text), std::move(n.why)});
return news;
}
void release(std::string_view plugin) override { host_.plugin_screens_release(plugin); }
// A player hidden from others, wholly or only their voice and sound (the roster and relay).
std::string server_hide(std::string_view plugin, std::uint64_t target, const std::vector<std::uint64_t> &viewers, const Hiding &what) override {
return host_.plugin_hide(plugin, target, viewers, what.skater, what.voice, what.sound);
}
std::string server_unhide(std::string_view plugin, std::uint64_t target, const std::vector<std::uint64_t> &viewers) override {
return host_.plugin_unhide(plugin, target, viewers);
}
void release(std::string_view plugin) override {
host_.plugin_screens_release(plugin);
host_.plugin_hides_release(plugin);
}
// The skaters of the players in the world, as their latest poses put them. The server has
// no skater of its own, so this is every player's.
std::vector<PeerSkater> peer_skaters() override {