diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 33697e12..3a45e633 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -515,6 +515,11 @@ bool Host::wants_tricks() const { [](const auto& item) { return item.second.plugin && item.second.plugin->has("events.tricks"); }); } +bool Host::wants_chat() const { + return std::any_of(entries_.begin(), entries_.end(), + [](const auto& item) { return item.second.plugin && item.second.plugin->has("chat.read"); }); +} + bool Host::run_console(std::string_view text) { if (commands_.empty()) return false; const auto parsed = parse_console_command(text); diff --git a/Extension/Plugins/plugin_host.h b/Extension/Plugins/plugin_host.h index b301b3ee..49bfe767 100644 --- a/Extension/Plugins/plugin_host.h +++ b/Extension/Plugins/plugin_host.h @@ -95,6 +95,9 @@ public: void trick(const TrickNews& news); // Whether a running plugin has events.tricks: the score HUD is read only then. bool wants_tricks() const; + // Whether a running plugin has chat.read: a dedicated server then hands it the /commands it + // does not know itself. + bool wants_chat() const; // Runs a plugin's console command when the text starts with one; false when none matched. bool run_console(std::string_view text); std::vector status() const; diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index e5138856..1bea7beb 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -329,6 +329,36 @@ int run(const std::filesystem::path &fixtures) { end) )"); + // One that hears chat and the /commands the server does not know, reads the map, and changes + // the map and the time of day for everyone. + write(root / "Plugins" / "talker" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", + "permissions": ["console", "chat.read", "session.read", "travel", "world.environment"]})"); + write(root / "Plugins" / "talker" / "plugin" / "main.lua", R"lua( + reskate.on("chat", function(line) + reskate.console.print(string.format("heard %s %s: %s%s", line.sender, line.name, line.text, line.server and " (server)" or "")) + end) + reskate.console.command("talker", "chat, the map and the time, for the test", function(args) + if args[1] == "world" then + local w = reskate.session.world() + reskate.console.print("world " .. w.world .. " map " .. w.map) + elseif args[1] == "levels" then + local n, named = 0, false + for _, level in reskate.travel.levels() do + n += 1 + if level.name == "Isle of Grom" and level.can_load then named = true end + end + reskate.console.print("levels " .. n .. " " .. tostring(named)) + elseif args[1] == "load" then + local ok, why = reskate.travel.load(table.concat(args, " ", 2)) + reskate.console.print("load " .. tostring(ok or why)) + elseif args[1] == "tod" then + local ok, why = reskate.world.set_time(args[2]) + reskate.console.print("tod " .. tostring(ok or why)) + end + end) + )lua"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -494,6 +524,40 @@ int run(const std::filesystem::path &fixtures) { plugin_command("asker poll 76561198000000099"); check(has(log, "poll that player is not on the server"), "A poll for nobody here was asked"); + // Chat: a player's line as it is passed on, the server's own, and a /command the server does + // not know (no "Unknown command" for it: the plugin answers); the server's own commands stay its. + log.clear(); + players[1].chat(now, "hello everyone"); + run_for(200000); + check(has(log, "heard 76561198000000002 Player 2: hello everyone"), "The plugin did not hear a player's chat line"); + log.clear(); + host.command("say the server speaks"); + run_for(200000); + check(has(log, "heard 0 Server: the server speaks (server)"), "The plugin did not hear the server's own line"); + log.clear(); + wire().sent[players[1].id].clear(); + players[1].chat(now, "/race join"); + run_for(200000); + check(has(log, "heard 76561198000000002 Player 2: /race join"), "A /command the server does not know did not reach the plugin"); + bool unknown{}; + for (const auto &bytes : std::exchange(wire().sent[players[1].id], {})) + if (auto p = decode_wire(bytes); p && p->kind == PacketKind::chat && p->text.find("Unknown command") != std::string::npos) unknown = true; + check(!unknown, "The player was told the plugin's /command is unknown"); + log.clear(); + players[1].chat(now, "/help"); + run_for(200000); + check(!has(log, "heard 76561198000000002 Player 2: /help"), "One of the server's own commands went to the plugin"); + + // The map and the world as the session's, and the maps the server knows. + plugin_command("talker world"); + check(has(log, "world 1 map " + host.map_name()), "session.world() is not the server's map and world"); + plugin_command("talker levels"); + check(has(log, "levels " + std::to_string(levels().size()) + " true"), "travel.levels() is not the server's maps"); + // The time of day for everyone needs the server's world layers and layer-sync. + plugin_command("talker tod night"); + check(has(log, "tod the server has no world layers") || has(log, "tod the server lets players keep their own world layers"), + "A time of day was set without the server's world layers"); + // The level, as players' games name it, and its change heard when the server changes it. const auto *first = find_level(config.map); const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); @@ -507,6 +571,21 @@ int run(const std::filesystem::path &fixtures) { check(has(log, "unloading " + first->asset) && has(log, "loaded " + other->asset), "The plugin did not hear the map change"); check(has(log, "level " + other->asset) && !has(log, "level " + first->asset), "level.current() did not follow the map change"); check(!has(log, "skaters 2"), "Skaters stayed on the old map's places after the map changed"); + // A plugin changes the map back, as the console's map does, with its checks. + plugin_command("talker load " + first->name); + { + std::string said; + for (const auto &line : log) said += "\n " + line; + check(has(log, "load true") && has(log, "[map] A plugin changed the map to "), "The plugin could not change the map:" + said); + } + run_for(1100000); + check(host.map_level() == first->asset, "The plugin's map change did not happen"); + check(host.plugin_map(first->name) == "the server is already on that map", "A change to the map in play was not refused"); + const auto nowhere = host.plugin_map("Nowhere At All"); + check(nowhere.starts_with("no single map is called") || nowhere == "this server does not have that map", + "A map the server does not have was loaded: " + nowhere); + plugin_command("talker load " + other->name); + check(has(log, "load one trip every 5 s"), "travel.load was not held to one trip every 5 s"); // A player who leaves with a poll open: the plugin hears it closed. log.clear(); host.drop(players[1].id, "testing"); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 15280049..50f4568b 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -827,25 +827,8 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply them to everyone.")); } if (name == "tod") { - // Every map's seven time layers ("_tod__"): one on and the rest off, or - // all back to the level's own. Set for every map, so it holds across map changes. - static constexpr std::array times{"default", "morning", "noon", "afternoon", - "evening", "night", "weatherday", "weathernight"}; - const auto wanted = lower(argument); - const auto found = std::find(times.begin(), times.end(), wanted); - if (found == times.end()) return "tod default|morning|noon|afternoon|evening|night|weatherday|weathernight"; - const auto slot = static_cast('0' + (found - times.begin())); - unsigned count{}; - for (const auto &layer : world_layers()) { - const auto at = layer.key.find("_tod_"); - if (at == std::string::npos || at + 5 >= layer.key.size()) continue; - if (slot == '0') config_.layers.erase(layer.key); - else config_.layers[layer.key] = layer.key[at + 5] == slot ? "on" : "off"; - ++count; - } - if (!count) return "world-layers.json has no time-of-day layers."; - apply_layers(); - return changed("Time of day set to " + std::string(*found) + + if (auto why = set_time_of_day(argument); !why.empty()) return why; + return changed("Time of day set to " + lower(argument) + (config_.world_layer_sync ? " for everyone." : ". Turn on layer-sync to apply it to everyone.")); } if (name == "layer") { @@ -1020,6 +1003,27 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (auto answer = plugins_->command(line)) return *answer; return "Unknown command \"" + std::string(action) + "\". Type help."; } +std::string Host::set_time_of_day(std::string_view time) { + // Every map's seven time layers ("_tod__"): one on and the rest off, or + // all back to the level's own. Set for every map, so it holds across map changes. + static constexpr std::array times{"default", "morning", "noon", "afternoon", + "evening", "night", "weatherday", "weathernight"}; + const auto wanted = lower(time); + const auto found = std::find(times.begin(), times.end(), wanted); + if (found == times.end()) return "tod default|morning|noon|afternoon|evening|night|weatherday|weathernight"; + const auto slot = static_cast('0' + (found - times.begin())); + unsigned count{}; + for (const auto &layer : world_layers()) { + const auto at = layer.key.find("_tod_"); + if (at == std::string::npos || at + 5 >= layer.key.size()) continue; + if (slot == '0') config_.layers.erase(layer.key); + else config_.layers[layer.key] = layer.key[at + 5] == slot ? "on" : "off"; + ++count; + } + if (!count) return "world-layers.json has no time-of-day layers."; + apply_layers(); + return {}; +} std::string Host::lock_name(std::uint64_t id, const Guest *guest) const { if (guest) return guest_name(*guest); if (const auto seen = seen_names_.find(id); seen != seen_names_.end()) return seen->second; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 8a432c69..2a7ccc34 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -806,7 +806,8 @@ bool custom_command_name_free(std::string_view name) noexcept { "object-placement", "object-scaling", "objects", "park", "park-mod", "parties", "party-size", "password", "placement", "players", "rate", "reserved", "rotation", "say", "score-allow", "score-check", "speed-check", "status", "teleport", "tod", "tpall", "tphere", "tps", "tuning", "tuning-enforce", "unban", "voice", "voice-allow", "voice-range", - "vote-cancel", "votes", "welcome", "world-layer-sync", "quit", "exit", "stop", "update"}) + "vote-cancel", "votes", "welcome", "world-layer-sync", "quit", "exit", "stop", "update", + "bone-reach", "lock", "locks", "unlock", "throwdown-locks", "words", "poll-run", "plugin"}) if (name == taken) return false; // A number is an answer to a poll ("/2"). return !std::all_of(name.begin(), name.end(), [](char c) { return c >= '0' && c <= '9'; }); diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 5947fe31..7ff9a932 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -891,7 +891,46 @@ void Host::send_chat(std::string_view text, Guest *only) { message.text = clean_chat_text(text); if (message.text.empty()) return; if (only) send_packet(*only, message, true, false); - else broadcast(message, true, false); + else { + broadcast(message, true, false); + record_chat(0, "Server", message.text, true); + } +} +void Host::record_chat(std::uint64_t sender, std::string name, std::string text, bool server) { + chat_log_.push_back({++chat_sequence_, sender, std::move(name), std::move(text), server}); + while (chat_log_.size() > 64) chat_log_.pop_front(); +} +std::vector Host::chat_since(std::uint64_t sequence) const { + std::vector out; + for (const auto &line : chat_log_) + if (line.sequence > sequence) out.push_back(line); + return out; +} +bool Host::chat_to_plugins(Guest &guest, std::string_view line) { + if (!plugins_ || !plugins_->hears_chat()) return false; + record_chat(guest.member.id, guest_name(guest), "/" + std::string(line), false); + return true; +} +std::string Host::plugin_map(std::string_view map) { + // As the console's map: a destination the server has, saved so it starts on it again. + if (map.empty() || !valid_map_destination(map_destination(map)) || !valid_map_destination(map_destination(map_setting(map)))) + return "no single map is called \"" + std::string(map) + "\" (travel.levels lists them)"; + if (!installed_map(map)) return "this server does not have that map"; + if (map_hash(map_destination(map)) == map_) return "the server is already on that map"; + change_map(map); + save(); + roster_dirty_ = true; + log_("[map] A plugin changed the map to " + map_name() + "."); + return {}; +} +std::string Host::plugin_time_of_day(std::string_view time) { + if (world_layers().empty()) return "the server has no world layers (world-layers.json beside it)"; + if (!config_.world_layer_sync) return "the server lets players keep their own world layers (layer-sync is off)"; + if (auto why = set_time_of_day(time); !why.empty()) return why; + save(); + roster_dirty_ = true; + log_("[world] A plugin set the time of day to " + std::string(time) + "."); + return {}; } bool Host::allowed_words(Guest &guest, std::string_view text) { if (!text::contains_forbidden_words(text)) return true; @@ -1359,6 +1398,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: } log_("[chat] " + guest_name(*link) + ": " + p.text); broadcast(p, true, false, p.source); + record_chat(peer, guest_name(*link), p.text, false); return; } // Grey dots above the sender. Not a chat line, and not logged. A player who may not diff --git a/Server/server_host.h b/Server/server_host.h index fbd0d98f..072e5389 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -14,6 +14,7 @@ #include "Extension/Multiplayer/Session/room.h" #include "Extension/Multiplayer/Steam/steam_transport.h" #include "Engine/Game/World/world_layers.h" +#include #include #include #include @@ -98,6 +99,20 @@ class Host { std::vector skater_poses() 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 + std::uint64_t world_id() const { return world_; } + // A server plugin changes the map, or the time of day for everyone, with the checks of the + // console's map and tod (and saved as they save): "" when done, else why not. + std::string plugin_map(std::string_view map); + std::string plugin_time_of_day(std::string_view time); + // The last chat lines, for the server's plugins (chat.read): each player's line as it is passed + // on, the server's own lines to everyone (sender 0), and a player's /command the server does + // not know itself, while a plugin hears chat. Sequences count from 1. + struct ChatRecord { + std::uint64_t sequence{}, sender{}; + std::string name, text; + bool server{}; + }; + std::vector chat_since(std::uint64_t sequence) const; // The park mods set up (maps.park_mods), for the server's plugins: which fit the map running // now, and which are spawned, with how many objects and minutes left (0: until removed). struct ParkStatus { @@ -515,6 +530,13 @@ class Host { void send_roster(); void send_world_state(); void send_chat(std::string_view text, Guest *only = nullptr); + std::deque chat_log_; + std::uint64_t chat_sequence_{}; + void record_chat(std::uint64_t sender, std::string name, std::string text, bool server); + // A /command the server does not know, for the plugins when one hears chat: true when taken. + bool chat_to_plugins(Guest &guest, std::string_view line); + // The time of day on every map (tod): "" when set, else why not. + std::string set_time_of_day(std::string_view time); void send_bans(Guest &admin); void send_maps(Guest &admin); std::string wire_map_label() const; diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index b2d715fd..6b9ce9c6 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -140,6 +140,30 @@ class ServerGame final : public Game { log_("[Plugins] " + answer); // the console's ban leaves logging to the console return {}; } + // Chat as the server passes it on: players' lines, the server's own to everyone, and the + // /commands it does not know itself (Host::chat_since). + std::vector chat_since(std::uint64_t sequence) override { + std::vector out; + for (auto &line : host_.chat_since(sequence)) out.push_back({line.sequence, line.sender, std::move(line.name), std::move(line.text), false, line.server}); + return out; + } + std::string world_map() override { return host_.map_name(); } + std::uint64_t world_id() override { return host_.world_id(); } + // The maps the server knows, which travel.load switches to (as the console's map does). + std::vector levels() override { + std::vector out; + for (const auto &level : server::levels()) out.push_back({level.asset, level.name, true}); + return out; + } + std::string travel(std::string_view kind, std::string_view target) override { + if (kind != "level") return "a dedicated server has no fast travel: load one of its maps (travel.load)"; + return host_.plugin_map(target); + } + // The time of day for everyone, as the console's tod sets it; the server has no other world. + std::string set_environment(std::string_view kind, std::string_view key, std::string_view) override { + if (kind != "tod") return unavailable; + return host_.plugin_time_of_day(key); + } 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; @@ -272,6 +296,8 @@ void ServerPlugins::tick(std::uint64_t now_us) { } } +bool ServerPlugins::hears_chat() const { return state_->plugins.wants_chat(); } + void ServerPlugins::receive(std::uint64_t from, const std::vector &bytes) { auto &inbox = state_->environment.inbox; const auto to = wire::to_of(bytes); diff --git a/Server/server_plugins.h b/Server/server_plugins.h index 144a3d06..eead353c 100644 --- a/Server/server_plugins.h +++ b/Server/server_plugins.h @@ -12,8 +12,9 @@ // a game mode's rules run. The operator installs them in Plugins beside the server, a folder a // plugin (with its reskate-plugin.json, "side" "server" or "both"); they load, reload and unload as // the folder changes, checked every 2 s. The server has no game: what a plugin may do here is the -// session's messages, its players, the server API (kick, say, tell, move), its storage and reading -// the content folders of the mods in Mods (mods.content). +// session's messages, its players and their chat, the server API (kick, say, tell, move), the map +// (session.world, travel.levels and travel.load) and the time of day for everyone (world.set_time), +// its storage and reading the content folders of the mods in Mods (mods.content). // Everything here runs on the server's tick thread. namespace dingosdk::server { class Host; @@ -32,6 +33,8 @@ class ServerPlugins { // A console line for the plugins: "plugin list|reload|off|on" or a plugin's own command. // Empty when it is neither. std::optional command(std::string_view line); + // Whether a running plugin hears chat (chat.read). + bool hears_chat() const; private: struct State; diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 67fdd4bc..24ace611 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -228,6 +228,9 @@ void Host::chat_command(Guest &guest, std::string_view line) { return run_custom_command(guest, custom_command, trim(rest)); // Admins run any server command from chat, as they do with "mp server". The caller // has already logged the line with any password hidden, so it is not logged again here. + // A word that is not one of the server's commands is the plugins' while one hears chat: a game + // mode's "/race join". They answer the player themselves (server.tell). + if (custom_command_name_free(verb) && chat_to_plugins(guest, line)) return; if (is_staff(guest.member.id)) { const auto id = guest.member.id; const auto answer_text = command(line, id);