diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 0391a85e..bf356d17 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,40 @@ 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")}); + // a tick first: the host takes the session's players then (Host::follow_session), so they are there to ask + ticks(server_host, server, 1); + ticks(player_host, player, 1); + ticks(blind_host, blind, 1); + 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 +3908,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 3a0020e6..608e8172 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});