From 02d34012aeac2b3fb1df5ba9105f896ec14dee52 Mon Sep 17 00:00:00 2001 From: vtf Date: Mon, 19 Oct 2026 20:09:30 -0400 Subject: [PATCH] Plugins: server plugins ban, unban; player permissions; reskate.Enums --- .gitignore | 4 + Extension/Plugins/plugin_api.cpp | 21 +++++ Extension/Plugins/plugin_api_world.cpp | 86 ++++++++++++++++++- Extension/Plugins/plugin_game.h | 10 +++ Server/Test/server_lock_tests.cpp | 4 +- Server/server_commands.cpp | 16 +++- Server/server_config.cpp | 4 +- Server/server_config.h | 5 +- Server/server_host.cpp | 2 +- Server/server_host.h | 6 +- Server/server_plugins.cpp | 32 ++++++- .../plugins/server-starter/plugin/main.lua | 14 +++ .../server-starter/reskate-plugin.json | 6 ++ 13 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 contrib/plugins/server-starter/plugin/main.lua create mode 100644 contrib/plugins/server-starter/reskate-plugin.json diff --git a/.gitignore b/.gitignore index a31f8b1c..1f79901d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ analysis/raw/**/*.res /assets/models/fpv/fpv_drone.glb /contrib/plugins/SmokeyDawg58-ReSkate_Throwdown /contrib/plugins/*.fbmod + +# vtf's linux development configuration +.vscode/ +.clangd \ No newline at end of file diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 242811c3..5efff252 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -600,6 +600,25 @@ void Api::install(Plugin& plugin, lua_State* L) { // "client" for the copy on a player's game, "server" for the session authority's copy. lua_pushstring(L, plugin.services_.authority() ? "server" : "client"); lua_setfield(L, -2, "side"); + // Named constants, read-only and built from the C++ lists so they cannot drift: + // reskate.Enums.Side.server is "server", reskate.Enums.Event.tick is "tick". Plain strings still work. + lua_createtable(L, 0, 2); + lua_createtable(L, 0, 3); + for (const auto side : {Side::client, Side::server, Side::both}) { + lua_pushstring(L, side_name(side)); + lua_setfield(L, -2, side_name(side)); + } + readonly(); + lua_setfield(L, -2, "Side"); + lua_createtable(L, 0, static_cast(events.size())); + for (const auto& event : events) { + lua_pushlstring(L, event.name.data(), event.name.size()); + lua_setfield(L, -2, std::string(event.name).c_str()); + } + readonly(); + lua_setfield(L, -2, "Event"); + readonly(); + lua_setfield(L, -2, "Enums"); set_function(L, "log", ApiFunctions::log); set_function(L, "on", ApiFunctions::on); @@ -697,5 +716,7 @@ void push_player(lua_State* L, const SessionPlayer& player) { lua_setfield(L, -2, "host"); lua_pushboolean(L, player.server); lua_setfield(L, -2, "server"); + lua_getfield(L, LUA_REGISTRYINDEX, "reskate.player"); // its permissions (plugin_api_world.cpp) + lua_setmetatable(L, -2); } } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index d0cd307a..557737a9 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1006,6 +1006,73 @@ struct WorldApi { const auto player = player_of(L, 1); return result(L, game(plugin).move_player(player, position_of(L, 2, "where the player goes"))); } + static int server_ban(lua_State* L) { + auto& plugin = authority(L, "reskate.server.ban"); + return result(L, game(plugin).ban(player_of(L, 1))); + } + static int server_unban(lua_State* L) { + auto& plugin = authority(L, "reskate.server.unban"); + return result(L, game(plugin).unban(player_of(L, 1))); + } + // A player's permissions: player.permissions is what the server holds for them now, read-only; + // permissions:clone() is a copy to change; player:apply_permissions(copy) makes it theirs. + static constexpr std::array, 5> permission_fields{{ + {"noclip", &Game::Permissions::noclip}, {"nobail", &Game::Permissions::nobail}, {"boosts", &Game::Permissions::boosts}, + {"tuning", &Game::Permissions::tuning}, {"objects", &Game::Permissions::objects}}}; + static void push_permissions(lua_State* L, const Game::Permissions& permissions, bool readonly) { + lua_createtable(L, 0, static_cast(permission_fields.size())); + for (const auto& [name, field] : permission_fields) { + lua_pushboolean(L, permissions.*field); + lua_setfield(L, -2, name); + } + lua_getfield(L, LUA_REGISTRYINDEX, "reskate.permissions"); + lua_setmetatable(L, -2); + if (readonly) lua_setreadonly(L, -1, true); + } + // Every permission, each true or false, and nothing else: a clone, so none is left out by mistake. + static Game::Permissions permissions_of(lua_State* L, int index) { + luaL_checktype(L, index, LUA_TTABLE); + Game::Permissions permissions; + for (const auto& [name, field] : permission_fields) { + lua_getfield(L, index, name); + if (!lua_isboolean(L, -1)) luaL_error(L, "permission \"%s\" must be true or false (clone the player's permissions to change them)", name); + permissions.*field = lua_toboolean(L, -1); + lua_pop(L, 1); + } + lua_pushnil(L); + while (lua_next(L, index)) { + const char* key = lua_type(L, -2) == LUA_TSTRING ? lua_tostring(L, -2) : nullptr; + const bool known = key && std::any_of(permission_fields.begin(), permission_fields.end(), + [&](const auto& f) { return std::string_view(f.first) == key; }); + if (!known) luaL_error(L, "there is no permission \"%s\"", key ? key : "(not a name)"); + lua_pop(L, 1); + } + return permissions; + } + static int permissions_clone(lua_State* L) { + push_permissions(L, permissions_of(L, 1), false); + return 1; + } + static int player_apply_permissions(lua_State* L) { + auto& plugin = authority(L, "player:apply_permissions"); + 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. + 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) != "permissions") return 0; + auto& plugin = authority(L, "player.permissions"); + const auto permissions = game(plugin).permissions(player_of(L, 1)); + if (!permissions) return 0; + push_permissions(L, *permissions, true); + return 1; + } // ---- throwdowns ------------------------------------------------------------------------------------- // host(mode, players, {max}): a drop where the skater stands for these players ("jam", // "spot_battle" or "skate"); it starts once they all join. One every 5 s. @@ -1478,6 +1545,22 @@ static void into(lua_State* L, const char* field, std::initializer_list permissions(std::uint64_t) { return std::nullopt; } + virtual std::string apply_permissions(std::string_view /*plugin*/, std::uint64_t, const Permissions&) { 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_lock_tests.cpp b/Server/Test/server_lock_tests.cpp index 638e3b4a..a9b81510 100644 --- a/Server/Test/server_lock_tests.cpp +++ b/Server/Test/server_lock_tests.cpp @@ -331,7 +331,9 @@ int run() { check(has(b.chat, "The server unlocked for you: nobail, boosts."), "Bailey was not told of the unlock"); command("unlock Dana all"); check(free(d) && d.permissions && (*d.permissions & role_movement), "Dana did not get their roster and movement back"); - check(command("unlock Dana") == "Dana has none of noclip, nobail, boosts, tuning locked.", "unlock with nothing locked"); + check(command("unlock Dana") == "Dana has none of noclip, nobail, boosts, tuning, objects locked.", "unlock with nothing locked"); + check(command("lock Dana objects") == "Locked for Dana: objects.", "objects did not lock by name"); + check(command("unlock Dana") == "Unlocked for Dana: objects.", "a bare unlock did not take back objects"); // ---- An admin's locks stay with the player when they leave and join again ----------------- c.leave(); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 7e8dc6f0..f0dad033 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -947,14 +947,14 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { // last word, so a name may have spaces in it. const bool lock = name == "lock"; auto who = trim(argument); - std::optional tools = lock ? std::nullopt : std::optional(lock_all); + std::optional tools = lock ? std::nullopt : std::optional(lock_all | lock_objects); if (const auto space = who.find_last_of(" \t"); space != std::string_view::npos) if (const auto named = parse_lock_tools(who.substr(space + 1))) { tools = named; who = trim(who.substr(0, space)); } if (who.empty() || !tools) - return lock ? "lock (tools: noclip, nobail, boosts, tuning)" + return lock ? "lock (tools: noclip, nobail, boosts, tuning, objects)" : "unlock []"; auto *guest = target(who); if (guest && !guest->handshaken) guest = nullptr; @@ -996,7 +996,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (argument.empty()) return "throwdown-locks |off (now " + (config_.throwdown_locks ? lock_tools_text(config_.throwdown_locks) : std::string("off")) + ")"; const auto tools = lower(argument) == "off" ? std::optional(0) : parse_lock_tools(argument); - if (!tools) return "throwdown-locks |off (tools: noclip, nobail, boosts, tuning)"; + if (!tools) return "throwdown-locks |off (tools: noclip, nobail, boosts, tuning, objects)"; // The players of a running throwdown get the new set at once: what it no longer locks // unlocked, what it now locks locked (none of it when it is turned off or on: then // update_throwdown_locks ends or starts theirs). @@ -1025,6 +1025,16 @@ std::string Host::lock_name(std::uint64_t id, const Guest *guest) const { if (const auto seen = seen_names_.find(id); seen != seen_names_.end()) return seen->second; return std::to_string(id); } +std::uint8_t Host::set_locks(std::uint64_t id, std::uint8_t locks, const std::string &source) { + auto *guest = find(id); + if (guest && !guest->handshaken) guest = nullptr; + // Locked as an admin locks, so a throwdown ending does not lift what was asked for here. + const auto found = locks_.find(id); + const std::uint8_t manual = found == locks_.end() ? 0 : found->second.manual; + const auto now = locks_of(id); + return change_locks(id, guest, locks & ~manual, true, LockBy::admin, source) | + change_locks(id, guest, now & ~locks, false, LockBy::admin, source); +} std::uint8_t Host::change_locks(std::uint64_t id, Guest *guest, std::uint8_t tools, bool lock, LockBy by, const std::string &source) { const bool automatic = by != LockBy::admin; auto &locks = locks_[id]; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 2c5c7d41..e62fb9f0 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -716,6 +716,7 @@ std::optional parse_lock_tools(std::string_view text) { else if (lower == "nobail") tools |= lock_nobail; else if (lower == "boosts") tools |= lock_boosts; else if (lower == "tuning") tools |= lock_tuning; + else if (lower == "objects") tools |= lock_objects; else if (lower == "all") tools |= lock_all; else return std::nullopt; } @@ -725,7 +726,8 @@ std::optional parse_lock_tools(std::string_view text) { std::string lock_tools_text(std::uint8_t tools) { std::string text; for (const auto &[bit, name] : {std::pair{lock_noclip, "noclip"}, std::pair{lock_nobail, "nobail"}, - std::pair{lock_boosts, "boosts"}, std::pair{lock_tuning, "tuning"}}) + std::pair{lock_boosts, "boosts"}, std::pair{lock_tuning, "tuning"}, + std::pair{lock_objects, "objects"}}) if (tools & bit) text += (text.empty() ? "" : ", ") + std::string(name); return text.empty() ? "none" : text; } diff --git a/Server/server_config.h b/Server/server_config.h index 8407d407..d29aafc2 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -88,8 +88,9 @@ inline constexpr unsigned max_spawned_parks = 16; // back, so a restart keeps it. inline constexpr unsigned dedicated_tps = 20; // The tools the server can take away from single players (lock, throwdown_locks): noclip with -// teleporting, No Bail, the boosts, and their own physics tuning. -inline constexpr std::uint8_t lock_noclip = 1, lock_nobail = 2, lock_boosts = 4, lock_tuning = 8; +// teleporting, No Bail, the boosts, their own physics tuning, and placing objects. "all" is the +// first four, as throwdown_locks has always read it: objects are only ever locked by name. +inline constexpr std::uint8_t lock_noclip = 1, lock_nobail = 2, lock_boosts = 4, lock_tuning = 8, lock_objects = 16; inline constexpr std::uint8_t lock_movement = lock_noclip | lock_nobail | lock_boosts; // what the role's "movement" gives inline constexpr std::uint8_t lock_all = lock_movement | lock_tuning; struct ServerConfig { diff --git a/Server/server_host.cpp b/Server/server_host.cpp index a1584f18..4d3d6eb0 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1524,7 +1524,7 @@ void Host::sync_objects() { // Guests upload their layouts; the server alone decides what everyone else // sees, and freezes the layouts of players who may not build right now. for (auto &[id, guest] : guests_) - if (guest->handshaken && guest->objects.revision() != guest->shared_from && now_ >= guest->objects_held_until && + if (guest->handshaken && !(locks_of(id) & lock_objects) && guest->objects.revision() != guest->shared_from && now_ >= guest->objects_held_until && (config_.object_placement == ObjectPlacement::everyone || (config_.object_placement == ObjectPlacement::host_only && has(id, multiplayer::role_objects)))) { auto layout = guest->objects.layout(); diff --git a/Server/server_host.h b/Server/server_host.h index f39a39a0..b7e76a63 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -76,6 +76,11 @@ class Host { void send_player_chat(std::uint64_t steam_id, std::string_view text); void broadcast_player_chat(std::string_view text); bool teleport_player(std::uint64_t steam_id, float x, float y, float z); + bool is_banned(std::uint64_t id) const; + // A player's locks (lock_*, server_config.h), and making them exactly `locks` as an admin's + // lock and unlock would (logged, and the player told): the tools that changed. + 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; void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); void change_map(std::string_view map); // a level name, level path or destination @@ -434,7 +439,6 @@ class Host { void send_words(Guest &); // the server's own filtered chat words (server_words.h) void send_next_maps(Guest &); // the maps the vote for the next one will offer void roles_changed(); // after any change to the roles or who has them - bool is_banned(std::uint64_t id) const; void save(); // `detail` is for the log alone: the player is told `reason`. diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 0af1305a..edd1a643 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -46,7 +46,7 @@ std::string fingerprint_of(const std::filesystem::path &directory) { // The server's game, for plugins: its players and what the server does to them. class ServerGame final : public Game { public: - explicit ServerGame(Host &host) : host_(host) {} + ServerGame(Host &host, ServerPlugins::Log log) : host_(host), log_(std::move(log)) {} std::string kick(std::uint64_t player, std::string_view why) override { if (!present(player)) return "that player is not on the server"; host_.drop(player, why.empty() ? std::string("Removed from the server.") : "Removed from the server: " + std::string(why), @@ -65,6 +65,33 @@ class ServerGame final : public Game { std::string move_player(std::uint64_t player, const Vec3 &at) override { return host_.teleport_player(player, at[0], at[1], at[2]) ? std::string{} : "that player is not on the server"; } + // Permissions are the player's locks turned around; any SteamID64, here or not, as `lock` takes. + std::optional permissions(std::uint64_t player) override { + if (!multiplayer::individual_steam_id(player)) return std::nullopt; + const auto locks = host_.player_locks(player); + return Permissions{!(locks & lock_noclip), !(locks & lock_nobail), !(locks & lock_boosts), !(locks & lock_tuning), + !(locks & lock_objects)}; + } + std::string apply_permissions(std::string_view plugin, std::uint64_t player, const Permissions &p) override { + if (!multiplayer::individual_steam_id(player)) return "not a player's SteamID64"; + const auto locks = static_cast((p.noclip ? 0 : lock_noclip) | (p.nobail ? 0 : lock_nobail) | (p.boosts ? 0 : lock_boosts) | + (p.tuning ? 0 : lock_tuning) | (p.objects ? 0 : lock_objects)); + host_.set_locks(player, locks, "plugin " + std::string(plugin)); + return {}; + } + // Through the console's own ban and unban, so they keep its checks and save the bans file. + std::string ban(std::uint64_t player) override { + const auto answer = host_.command("ban " + std::to_string(player)); + if (!host_.is_banned(player)) return answer; + log_("[Plugins] " + answer); // the console's ban leaves logging to the console + return {}; + } + std::string unban(std::uint64_t player) override { + const auto answer = host_.command("unban " + std::to_string(player)); + if (host_.is_banned(player)) return answer; + log_("[Plugins] " + answer); + return {}; + } private: bool present(std::uint64_t player) const { @@ -73,12 +100,13 @@ class ServerGame final : public Game { return false; } Host &host_; + ServerPlugins::Log log_; }; class ServerEnvironment final : public Environment { public: ServerEnvironment(Host &host, std::filesystem::path storage, ServerPlugins::Log log) - : host_(host), storage_(std::move(storage)), log_(std::move(log)), game_(host) {} + : host_(host), storage_(std::move(storage)), log_(log), game_(host, log) {} void log(LogLevel level, std::string_view text) override { log_(std::string(level == LogLevel::error ? "[Plugins] error: " : level == LogLevel::warn ? "[Plugins] warning: " : "[Plugins] ") + std::string(text)); diff --git a/contrib/plugins/server-starter/plugin/main.lua b/contrib/plugins/server-starter/plugin/main.lua new file mode 100644 index 00000000..037c3d29 --- /dev/null +++ b/contrib/plugins/server-starter/plugin/main.lua @@ -0,0 +1,14 @@ +-- A starting point for a server plugin: greets each player who joins and counts their visits, +-- and gives the server console a `starter` command. Copy the folder into the server's Plugins. +reskate.on("player_joined", function(player) + local visits = (reskate.storage.get("visits." .. player.id) or 0) + 1 + reskate.storage.set("visits." .. player.id, visits) + reskate.server.tell(player.id, "Welcome, " .. player.name .. " (visit " .. visits .. ")") +end) + +reskate.console.command("starter", "Server starter plugin: starter say ", function(args) + if args[1] ~= "say" or not args[2] then return reskate.console.print("starter say ") end + reskate.server.say(table.concat(args, " ", 2)) +end) + +reskate.log("server-starter loaded") diff --git a/contrib/plugins/server-starter/reskate-plugin.json b/contrib/plugins/server-starter/reskate-plugin.json new file mode 100644 index 00000000..b6713ddc --- /dev/null +++ b/contrib/plugins/server-starter/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "server", + "permissions": ["console", "storage", "session.read", "server"] +}