From 71e3460182d1f7539102637332e58557805d181e Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Fri, 23 Oct 2026 03:22:47 +0200 Subject: [PATCH] Plugins: server plugins know each player's role A server plugin could not see the roles of a dedicated server (data/roles.json), so a plugin that wanted only moderators to use some of its commands had to keep its own list of SteamIDs. In a server copy with the server permission, a player table now has role: a read-only {name, rank, staff}, the role the player has now (the one given them, or the server's default), read from the server each time. staff is whether the role gives any of the server's commands. It is nil for a player with no role, for the server itself and on a lobby's host. Each read counts as a server call. dingosdk_server_plugin_tests reads a given role, the default role and a role given while the player is on, and checks the table is read-only and a plugin without server is refused; dingosdk_plugin_tests checks the API on a server copy, and that a player's copy and a plugin without server cannot read it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs7xxJDf6SDcQUaXnzmtL5 --- Extension/Plugins/Test/plugin_tests.cpp | 35 +++++++++++++++++++ Extension/Plugins/plugin_api_world.cpp | 20 +++++++++-- Extension/Plugins/plugin_game.h | 9 +++++ Server/Test/server_plugin_tests.cpp | 46 +++++++++++++++++++++++-- Server/server_host.h | 2 ++ Server/server_plugins.cpp | 7 ++++ 6 files changed, 114 insertions(+), 5 deletions(-) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 0391a85e..5375b681 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -546,6 +546,10 @@ struct FakeGame final : Game { calls.push_back(std::format("announce {} {}", plugin, text)); return {}; } + std::optional role(std::uint64_t player) override { + if (player == 1) return Role{"moderator", 50, true}; + return std::nullopt; + } std::vector server_parks() override { return {{"street", "San Vansterdam", true, true, 12, 5}, {"plaza", "Isle of Grom", false, false, 0, 0}}; } std::string spawn_park(std::string_view plugin, std::string_view name, unsigned minutes) override { calls.push_back(std::format("park spawn {} {} {}", plugin, name, minutes)); @@ -2108,6 +2112,36 @@ void plugins_server_copies_spawn_park_mods() { "A plugin without server.parks used the park mods\n" + blind.all()); } +// A player's role, for a server copy with server: read-only, nil for one with none. Not for a copy +// without the permission, nor for a player's copy. +void plugins_server_copies_see_roles() { + TestEnvironment server, player, blind; + for (auto* environment : {&server, &player, &blind}) environment->session_now = {true, false, {{1, "Ann", false, false}, {2, "Bob", false, false}}}; + const std::string main = R"( + reskate.console.command("roles", "x", function() + local ann, bob = reskate.session.players()[1], reskate.session.players()[2] + local role = ann.role + assert(role and role.name == "moderator" and role.rank == 50 and role.staff == true) + assert(not pcall(function() role.rank = 1000 end), "read-only") + assert(bob.role == nil, "nil with no role") + reskate.log("roles done") + end) + )"; + const auto roles = [&](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({roles("roles-s", R"("console", "session.read", "server")", "server")}); + player_host.apply({roles("roles-p", R"("console", "session.read", "server")", "client")}); + blind_host.apply({roles("roles-b", R"("console", "session.read")", "server")}); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("roles"); + check(server.logged("[roles-s] roles done"), "A server copy could not read the players' roles\n" + server.all()); + check(!player.logged("roles done") && player.all().find("server copy") != std::string::npos, + "A player's copy read the players' roles\n" + player.all()); + check(!blind.logged("roles done") && blind.all().find("\"server\"") != std::string::npos, + "A plugin without server read the players' roles\n" + blind.all()); +} + // Clips on the skater's body: from the package or recorded, played, replacing a trick. void plugins_animate_the_skater() { TestEnvironment environment; @@ -3870,6 +3904,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_see_roles); run(plugins_animate_the_skater); run(plugins_play_their_own_sounds); run(plugins_have_menu_panels); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 40f72379..c30b0c13 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -2152,14 +2152,30 @@ struct WorldApi { const auto player = player_of(L, 1); return result(L, game(plugin).apply_permissions(plugin.id(), player, permissions_of(L, 2))); } - // A player table's own names: permissions (read when asked, so always the server's now) and - // apply_permissions. Anything else a player table does not have is nil. + // player.role: {name, rank, staff}, read-only, or nil for a player with no role. + static int player_role(lua_State* L) { + auto& plugin = authority(L, "player.role"); + const auto role = game(plugin).role(player_of(L, 1)); + if (!role) return 0; + lua_createtable(L, 0, 3); + lua_pushlstring(L, role->name.data(), role->name.size()); + lua_setfield(L, -2, "name"); + lua_pushinteger(L, role->rank); + lua_setfield(L, -2, "rank"); + lua_pushboolean(L, role->staff); + lua_setfield(L, -2, "staff"); + lua_setreadonly(L, -1, true); + return 1; + } + // A player table's own names: permissions and role (read when asked, so always the server's + // now) and apply_permissions. Anything else a player table does not have is nil. static int player_index(lua_State* L) { const char* key = lua_type(L, 2) == LUA_TSTRING ? lua_tostring(L, 2) : ""; if (std::string_view(key) == "apply_permissions") { lua_pushcfunction(L, player_apply_permissions, "apply_permissions"); return 1; } + if (std::string_view(key) == "role") return player_role(L); if (std::string_view(key) != "permissions") return 0; auto& plugin = authority(L, "player.permissions"); const auto permissions = game(plugin).permissions(player_of(L, 1)); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 6ea2fa9f..09374755 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -596,6 +596,15 @@ public: }; virtual std::optional permissions(std::uint64_t) { return std::nullopt; } virtual std::string apply_permissions(std::string_view /*plugin*/, std::uint64_t, const Permissions&) { return unavailable; } + // A player's role on a dedicated server (data/roles.json): its name, its rank (0 to 1000), and + // whether it gives any of the server's commands. Nothing for a player with no role, nor where + // there are no roles (a lobby's host). + struct Role { + std::string name; + int rank{}; + bool staff{}; + }; + virtual std::optional role(std::uint64_t) { return std::nullopt; } // A dedicated server's park mods (server.parks): those it has set up, whether each fits the map // running now and is spawned (its objects, and minutes left; 0 until removed). Spawning one (for // `minutes`, 0 until removed) or removing it goes as the server's own park-mod does. diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 4a682735..2a791e0b 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -1,5 +1,6 @@ // The dedicated server's plugins (server_plugins.cpp) with the server's host, over a transport -// that is not Steam's and simulated players: what a server plugin sees of the players' skaters. +// that is not Steam's and simulated players: what a server plugin sees of the players' skaters +// and roles. // // dingosdk_server_plugin_tests #include "Server/server_host.h" @@ -177,6 +178,7 @@ int run(const std::filesystem::path &fixtures) { config.auto_update = false; config.activity_log = false; config.roles = default_roles(); + config.roles.players[76561198000000001ULL] = "moderator"; // the second player has the default role config.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, {"plaza", "mixed park", "Isle of Grom", "grom"}}; check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); @@ -251,6 +253,32 @@ int run(const std::filesystem::path &fixtures) { end) )"); + // One that says each player's role, and one without server that may not know them. + write(root / "Plugins" / "ranks" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.read", "server"]})"); + write(root / "Plugins" / "ranks" / "plugin" / "main.lua", R"( + reskate.console.command("ranks", "players' roles, for the test", function() + for _, p in ipairs(reskate.session.players()) do + local role = p.role + if role then + reskate.console.print(string.format("role %s %s rank %d staff %s", p.id, role.name, role.rank, tostring(role.staff))) + reskate.console.print(pcall(function() role.rank = 1000 end) and "role changed" or "role read-only") + elseif not p.server then + reskate.console.print("role " .. p.id .. " none") + end + end + end) + )"); + write(root / "Plugins" / "rankless" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.read"]})"); + write(root / "Plugins" / "rankless" / "plugin" / "main.lua", R"( + reskate.console.command("rankless", "roles without server", function() + local p = reskate.session.players()[1] + local ok, why = pcall(function() return p.role end) + reskate.console.print(ok and "rankless saw a role" or "rankless refused: " .. tostring(why)) + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -320,12 +348,24 @@ int run(const std::filesystem::path &fixtures) { run_for(1100000); check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses"); - // Park mods: listed with whether they fit this map; spawned and removed through park-mod's own - // checks, logged by the plugin's name. + // Roles: the one given (moderator), the server's default (player) for the other, read-only; + // and nothing for a plugin without server. const auto plugin_command = [&](std::string_view line) { log.clear(); check(host.command(line) == "Ran by a plugin.", std::string(line) + " did not reach the plugin"); }; + plugin_command("ranks"); + check(has(log, "role 76561198000000001 moderator rank 50 staff true"), "The moderator's role is not what the server gave them"); + check(has(log, "role 76561198000000002 player rank 0 staff false"), "A player given no role does not have the default role"); + check(has(log, "role read-only") && !has(log, "role changed"), "A plugin changed a player's role table"); + host.command("role 76561198000000002 admin"); + plugin_command("ranks"); + check(has(log, "role 76561198000000002 admin rank 100 staff true"), "A role given while they are on was not seen"); + plugin_command("rankless"); + check(has(log, "rankless refused: ") && has(log, "server"), "A plugin without server saw a role"); + + // Park mods: listed with whether they fit this map; spawned and removed through park-mod's own + // checks, logged by the plugin's name. plugin_command("parker list"); check(has(log, "park street on San Vansterdam, here true, spawned false, 0 objects, 0 min left"), "street is not listed as it is"); check(has(log, "park plaza on Isle of Grom, here false, spawned false"), "plaza is not listed as on another map"); diff --git a/Server/server_host.h b/Server/server_host.h index 2ad1843c..bc40b25b 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -85,6 +85,8 @@ class Host { std::uint8_t player_locks(std::uint64_t id) const { return locks_of(id); } std::uint8_t set_locks(std::uint64_t id, std::uint8_t locks, const std::string &source); std::optional> player_position(std::uint64_t steam_id) const; + // The role a player has (data/roles.json: given them, or the default), here or not; or none. + const Role *player_role(std::uint64_t id) const { return role_of(id); } // Where each player in the world last said their skater was (their latest pose's root), and // how long ago that arrived: what the server's plugins see of the skaters. struct SkaterPose { diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 5eeb453e..a6913d1e 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -80,6 +80,13 @@ class ServerGame final : public Game { host_.set_locks(player, locks, "plugin " + std::string(plugin)); return {}; } + // The role they have (the one given them, or the server's default role), here or not. + std::optional role(std::uint64_t player) override { + if (!multiplayer::individual_steam_id(player)) return std::nullopt; // the server itself has none + const auto *held = host_.player_role(player); + if (!held) return std::nullopt; + return Role{held->name, held->rank, server::staff(held)}; + } std::vector server_parks() override { std::vector result; for (auto &p : host_.park_status()) result.push_back({std::move(p.name), std::move(p.map), p.here, p.spawned, p.objects, p.minutes_left});