mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-26 00:54:36 +01:00
Plugins: server plugins ban, unban; player permissions; reskate.Enums
This commit is contained in:
1 parent
0ff96018d0
commit
02d34012ae
13 files changed
+198
-12
No files matched your search
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user