Plugins: server plugins ban, unban; player permissions; reskate.Enums

This commit is contained in:
vtf committed 2026-10-19 20:09:30 -04:00
1 parent 0ff96018d0
commit 02d34012ae
13 files changed
+198 -12

No files matched your search

+4
View File
@@ -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
+21
View File
@@ -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<int>(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);
}
}
+85 -1
View File
@@ -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<std::pair<const char*, bool Game::Permissions::*>, 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<int>(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<std::pai
void WorldApi::install(Plugin& plugin, lua_State* L) {
(void)plugin;
// The metatables of player tables (push_player) and of permissions; their own stay hidden.
lua_createtable(L, 0, 2);
lua_pushcfunction(L, player_index, "player");
lua_setfield(L, -2, "__index");
lua_pushstring(L, "locked");
lua_setfield(L, -2, "__metatable");
lua_setfield(L, LUA_REGISTRYINDEX, "reskate.player");
lua_createtable(L, 0, 2);
lua_createtable(L, 0, 1);
lua_pushcfunction(L, permissions_clone, "clone");
lua_setfield(L, -2, "clone");
lua_setreadonly(L, -1, true);
lua_setfield(L, -2, "__index");
lua_pushstring(L, "locked");
lua_setfield(L, -2, "__metatable");
lua_setfield(L, LUA_REGISTRYINDEX, "reskate.permissions");
into(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}, {"xray", xray},
{"grind_exit", grind_exit}});
into(L, "board", {{"motion", board_motion}, {"bones", board_bones}, {"set", board_set}, {"hide", board_hide}, {"clear", board_clear},
@@ -1497,7 +1580,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
{"record", animation_record}, {"recorded", animation_recorded}, {"forget", animation_forget}, {"trick", animation_trick}});
into(L, "outfit", {{"set", outfit_set}, {"clear", outfit_clear}, {"find", outfit_find}, {"slots", outfit_slots}, {"status", outfit_status}});
into(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}});
into(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}});
into(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move},
{"ban", server_ban}, {"unban", server_unban}});
into(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting},
{"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}, {"release_flag", throwdown_release_flag}});
into(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous},
+10
View File
@@ -286,6 +286,16 @@ public:
virtual std::string announce(std::string_view /*plugin*/, std::string_view) { return unavailable; }
virtual std::string tell(std::string_view /*plugin*/, std::uint64_t, std::string_view) { return unavailable; }
virtual std::string move_player(std::uint64_t, const Vec3&) { return unavailable; }
// Bans a player by SteamID64, connected or not (a dedicated server's only); lifts a ban.
virtual std::string ban(std::uint64_t) { return unavailable; }
virtual std::string unban(std::uint64_t) { return unavailable; }
// A player's permissions, true where they may: what the server holds for them now, or nothing
// for one it cannot hold any for. Applying makes them exactly these.
struct Permissions {
bool noclip = true, nobail = true, boosts = true, tuning = true, objects = true;
};
virtual std::optional<Permissions> 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.
+3 -1
View File
@@ -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();
+13 -3
View File
@@ -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<std::uint8_t> tools = lock ? std::nullopt : std::optional<std::uint8_t>(lock_all);
std::optional<std::uint8_t> tools = lock ? std::nullopt : std::optional<std::uint8_t>(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 <player|SteamID64> <tool[,tool...]|all> (tools: noclip, nobail, boosts, tuning)"
return lock ? "lock <player|SteamID64> <tool[,tool...]|all> (tools: noclip, nobail, boosts, tuning, objects)"
: "unlock <player|SteamID64> [<tool[,tool...]|all>]";
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 <tool[,tool...]|all>|off (now " + (config_.throwdown_locks ? lock_tools_text(config_.throwdown_locks) : std::string("off")) + ")";
const auto tools = lower(argument) == "off" ? std::optional<std::uint8_t>(0) : parse_lock_tools(argument);
if (!tools) return "throwdown-locks <tool[,tool...]|all>|off (tools: noclip, nobail, boosts, tuning)";
if (!tools) return "throwdown-locks <tool[,tool...]|all>|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];
+3 -1
View File
@@ -716,6 +716,7 @@ std::optional<std::uint8_t> 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<std::uint8_t> 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;
}
+3 -2
View File
@@ -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 {
+1 -1
View File
@@ -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();
+5 -1
View File
@@ -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<std::array<float, 3>> 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`.
+30 -2
View File
@@ -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> 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<std::uint8_t>((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));
@@ -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 <text>", function(args)
if args[1] ~= "say" or not args[2] then return reskate.console.print("starter say <text>") end
reskate.server.say(table.concat(args, " ", 2))
end)
reskate.log("server-starter loaded")
@@ -0,0 +1,6 @@
{
"api": 1,
"entry": "plugin/main.lua",
"side": "server",
"permissions": ["console", "storage", "session.read", "server"]
}