Merge pull request #351 from moelrobi/plugins/server-cards-content-w2s

Plugins: server cards and polls, mods.content on dedicated servers, world_to_screen bounds

Conflicts in the plugin and server plugin tests, where #348's role tests and this PR's tests were
added in the same places: both kept.
This commit is contained in:
AyeZeeBB committed 2026-10-23 15:56:02 -05:00
commit 60cd8395d9
16 files changed
+765 -61

No files matched your search

+146
View File
@@ -1,5 +1,6 @@
// The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload,
// and the sandbox against plugins written to break it (loops, memory, error floods, escapes).
#include "Extension/Plugins/plugin_content.h"
#include "Extension/Plugins/plugin_discovery.h"
#include "Extension/Plugins/plugin_host.h"
#include "Extension/Plugins/plugin_manifest.h"
@@ -559,6 +560,31 @@ struct FakeGame final : Game {
calls.push_back(std::format("park remove {} {}", plugin, name));
return {};
}
static std::string players_text(const std::vector<std::uint64_t>& players) {
std::string text;
for (const auto id : players) text += (text.empty() ? "" : ",") + std::to_string(id);
return text.empty() ? "all" : text;
}
std::string server_card(std::string_view plugin, const std::vector<std::uint64_t>& players, std::string_view text, unsigned seconds) override {
calls.push_back(std::format("card {} {} {} {}", plugin, players_text(players), text, seconds));
return {};
}
std::uint32_t next_poll = 41;
std::string server_poll(std::string_view plugin, const std::vector<std::uint64_t>& players, std::string_view question,
const std::vector<std::string>& answers, unsigned seconds, std::uint32_t& id) override {
std::string listed;
for (const auto& answer : answers) listed += (listed.empty() ? "" : "|") + answer;
calls.push_back(std::format("poll {} {} {} {} {}", plugin, players_text(players), question, listed, seconds));
if (players == std::vector<std::uint64_t>{9}) return "that player is not on the server";
id = next_poll++;
return {};
}
std::string server_poll_close(std::string_view plugin, std::uint32_t id) override {
calls.push_back(std::format("poll close {} {}", plugin, id));
return {};
}
std::vector<PollNews> poll_news;
std::vector<PollNews> take_poll_news() override { return std::exchange(poll_news, {}); }
std::optional<CameraView> camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; }
std::string set_camera(std::string_view plugin, const std::array<float, 16>& pose, float fov) override {
calls.push_back(std::format("camera {} {:.1f} {:.1f} {:.1f} back {:.2f} {:.2f} {:.2f} fov {:.0f}", plugin, pose[12], pose[13], pose[14],
@@ -1745,11 +1771,17 @@ void plugins_draw_on_the_overlay() {
local on = d:world_to_screen(vector.create(1, 0, -10))
assert(on and math.abs(on.x - 1060) < 0.01 and math.abs(on.z - 10) < 0.01, "world_to_screen")
assert(d:world_to_screen(vector.create(0, 0, 5)) == nil, "behind the camera")
-- Just in front of the camera and off to the side the point is millions of pixels
-- out, which d:poly would refuse.
assert(d:world_to_screen(vector.create(1000, 0, -0.06)) == nil, "far off the screen")
assert(d:world_to_screen(vector.create(0, 1000, -0.06)) == nil, "far above the screen")
assert(d:world_to_screen(vector.create(1, 0, -0.06)), "off the screen but drawable")
assert(d:size().x == 1920 and d:size().y == 1080)
assert(not pcall(d.text, d, vector.create(0, 0, 0), "x", {colour = "red"}), "a colour by name")
assert(not pcall(d.text, d, vector.create(0 / 0, 0, 0), "x"), "NaN")
assert(not pcall(d.poly, d, {vector.create(0, 0, 0)}), "a polygon of one point")
assert(not pcall(d.text, d, vector.create(0, 0, 0), string.rep("x", 600)), "a long text")
reskate.log("drawn")
end)
reskate.console.command("outside", "x", function()
local ok, err = pcall(saved.text, saved, vector.create(0, 0, 0), "x")
@@ -1773,6 +1805,7 @@ void plugins_draw_on_the_overlay() {
running(host, "painter", environment);
check(environment.layers.size() == 1 && environment.layers[0].plugin == "painter" && environment.layers[0].shapes.size() == 8,
"Its shapes are handed to the overlay each tick\n" + environment.all());
check(environment.logged("drawn"), "and the draw handler ran to its end\n" + environment.all());
const auto& first = environment.layers[0].shapes[0];
check(first.kind == plugin_ui::Shape::Kind::text && first.text == "hello" && first.style.colour == 0xff0080ff &&
first.style.size == 24 && first.style.align == 1, "with their style (colours as ImGui keeps them)");
@@ -2146,6 +2179,69 @@ void plugins_server_copies_see_roles() {
"A plugin without server read the players' roles\n" + blind.all());
}
// Cards and polls on players' screens, for a server copy with "server": for a player, a list of
// them or "all", with checked text, and each answer or close coming back as an event to the plugin
// that asked. Not for a copy without the permission, nor for a player's copy.
void plugins_server_copies_show_cards_and_polls() {
TestEnvironment server, player, blind;
const std::string main = R"(
local heard = pcall(reskate.on, "poll_answer", function(e)
reskate.log(string.format("answer %d %s %d %s", e.id, e.player, e.answer, e.text))
end)
pcall(reskate.on, "poll_closed", function(e) reskate.log(string.format("closed %d %s %s", e.id, e.player, e.why)) end)
if not heard then reskate.log("no poll events") end
reskate.console.command("cards", "x", function()
assert(reskate.server.card("all", "Race in 30 s"))
assert(reskate.server.card({"7", {id = "8"}}, "Ready?", 5))
assert(reskate.server.card({id = "7"}, "Go"))
local id = assert(reskate.server.poll("all", "Next track?", {"Docks", "Hill"}, {seconds = 20}))
assert(id == 41, tostring(id))
assert(reskate.server.poll("7", "Again?", {"Yes", "No", "Later"}) == 42)
local none, why = reskate.server.poll("9", "Anyone?", {"a", "b"})
assert(none == nil and why == "that player is not on the server", tostring(why))
assert(reskate.server.poll_close(41))
assert(not pcall(reskate.server.card, "all", ""), "a card says something")
assert(not pcall(reskate.server.card, "all", string.rep("x", 121)), "120 characters at most")
assert(not pcall(reskate.server.card, "all", "x", 0), "1 to 60 s")
assert(not pcall(reskate.server.card, "all", "x", 61), "1 to 60 s")
assert(not pcall(reskate.server.card, "everyone", "x"), "a player, a list of them or all")
assert(not pcall(reskate.server.card, {}, "x"), "at least one player")
assert(not pcall(reskate.server.poll, "all", "", {"a", "b"}), "a question")
assert(not pcall(reskate.server.poll, "all", "q?", {"only one"}), "2 to 6 answers")
assert(not pcall(reskate.server.poll, "all", "q?", {"1", "2", "3", "4", "5", "6", "7"}), "2 to 6 answers")
assert(not pcall(reskate.server.poll, "all", "q?", {"a", string.rep("x", 49)}), "48 characters an answer at most")
assert(not pcall(reskate.server.poll, "all", "q?", {"a", ""}), "an answer says something")
assert(not pcall(reskate.server.poll, "all", "q?", {"a", "b"}, {seconds = 4}), "5 to 300 s")
assert(not pcall(reskate.server.poll_close, "x"), "a poll's id")
reskate.log("cards done")
end)
)";
const auto cards = [&](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({cards("cards-s", R"("console", "server")", "server")});
player_host.apply({cards("cards-p", R"("console", "server")", "client")});
blind_host.apply({cards("cards-b", R"("console")", "server")});
for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("cards");
check(server.logged("[cards-s] cards done"), "A server copy could not show cards and polls\n" + server.all());
for (const auto* call : {"card cards-s all Race in 30 s 0", "card cards-s 7,8 Ready? 5", "card cards-s 7 Go 0",
"poll cards-s all Next track? Docks|Hill 20", "poll cards-s 7 Again? Yes|No|Later 0", "poll close cards-s 41"})
check(server.fake.called(call), std::string("The server was asked: ") + call + "\n" + server.all());
check(!server.fake.called("card cards-s all 0") && !server.fake.called("poll cards-s all q? only one 0"), "A refused call reached the server");
// Answers and closes, each to the plugin that asked.
server.fake.poll_news = {{"cards-s", 41, 7, 2, "Hill", ""}, {"cards-s", 42, 7, 0, "", "timeout"}, {"someone", 50, 8, 1, "Docks", ""}};
ticks(server_host, server, 1);
check(server.logged("[cards-s] answer 41 7 2 Hill") && server.logged("[cards-s] closed 42 7 timeout"),
"The plugin hears its polls answered and closed\n" + server.all());
check(!server.logged("answer 50"), "and only its own");
check(!player.logged("cards done") && player.all().find("server copy") != std::string::npos,
"A player's copy showed cards\n" + player.all());
check(!blind.logged("cards done") && blind.all().find("\"server\"") != std::string::npos && blind.logged("no poll events"),
"A plugin without server showed cards or heard polls\n" + blind.all());
running(server_host, "cards-s", server);
}
// Clips on the skater's body: from the package or recorded, played, replacing a trick.
void plugins_animate_the_skater() {
TestEnvironment environment;
@@ -2924,6 +3020,54 @@ void a_plugin_reads_the_content_folder_it_names() {
check(environment.printed("content ok") && game.called("content list parks"), "The plugin reads its content folder\n" + environment.all());
}
// What the game and a dedicated server read for mods.content (plugin_content.h), from real folders:
// every mod folder in a Mods folder by name, each one's files under the folder asked for, and the
// limits a plugin cannot get past.
void content_is_read_from_the_mod_folders() {
namespace content = dingosdk::plugins::content;
const auto mods = scratch / "content mods";
const auto write = [](const std::filesystem::path& file, const std::string& text) {
std::filesystem::create_directories(file.parent_path());
std::ofstream(file, std::ios::binary) << text;
};
write(mods / "b_track" / "parks" / "start.json", "{}");
write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "level4.json", "{}");
write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "d" / "level5.json", "{}");
write(mods / "b_track" / "parks" / "half.tmp", "");
write(mods / "b_track" / "parks" / "big.json", std::string((1u << 20) + 1, ' '));
write(mods / "b_track" / "manifest.json", R"({"name": "B Track", "version_number": "1.0.0"})");
write(mods / "A_Park" / "parks" / "z.json", "zz");
write(mods / "A_Park" / "reskate-levels.json", "{}");
write(mods / "no parks" / "manifest.json", "not JSON");
write(mods / ".reskate_plugin_data" / "parks" / "secret.json", "{}");
write(mods / "loose.json", "{}");
const auto found = content::mod_folders(mods);
check(found.size() == 3 && found[0].name == "A_Park" && found[1].name == "b_track" && found[2].name == "no parks",
"Every mod folder, by name, none hidden");
check(found[0].title == "A_Park" && found[1].title == "B Track" && found[2].title == "no parks", "titled as their manifest names them");
check(content::mod_folders(scratch / "no such folder").empty(), "No Mods folder, no mods");
std::vector<Game::ContentFile> files;
content::list(found, "parks", files);
std::string listed;
for (const auto& file : files) listed += std::format("{}:{}:{} ", file.mod, file.path, file.size);
check(listed == std::format("A_Park:z.json:2 b_track:big.json:{} b_track:deep/a/b/c/level4.json:2 b_track:start.json:2 ", (1u << 20) + 1),
"Mod by mod, sorted, four folders deep at most, no .tmp: " + listed);
std::string text;
check(content::read(found, "B_TRACK", "parks", "start.json", text).empty() && text == "{}", "A file is read, the mod named in any case");
check(content::read(found, "b_track", "parks", "missing.json", text) == "not found", "A missing file");
check(content::read(found, "b_track", "parks", "big.json", text) == "the file is larger than 1 MB", "A file over 1 MB");
check(content::read(found, "nobody", "parks", "start.json", text) == "no such mod is on", "A mod that is not there");
check(content::read(found, ".reskate_plugin_data", "parks", "secret.json", text) == "no such mod is on", "A hidden folder is no mod");
for (int i = 0; i < 1005; ++i) write(mods / "c_many" / "parks" / std::format("{:04}.json", i), "");
files.clear();
content::list(content::mod_folders(mods), "parks", files);
check(files.size() == content::most_files, std::format("At most {} files, {} listed", content::most_files, files.size()));
}
void board_break_snaps_the_board_on_a_slam() {
TestEnvironment environment;
std::filesystem::remove_all(scratch / "storage" / "board-break");
@@ -3909,6 +4053,7 @@ int main() {
run(plugins_server_copies_are_the_authority);
run(plugins_server_copies_spawn_park_mods);
run(plugins_server_copies_see_roles);
run(plugins_server_copies_show_cards_and_polls);
run(plugins_animate_the_skater);
run(plugins_play_their_own_sounds);
run(plugins_have_menu_panels);
@@ -3928,6 +4073,7 @@ int main() {
run(dual_stick_edits_the_companions_settings);
run(rock_to_fakie_relabels_and_records_the_stall);
run(a_plugin_reads_the_content_folder_it_names);
run(content_is_read_from_the_mod_folders);
run(board_break_snaps_the_board_on_a_slam);
run(fpv_drone_flies_and_follows);
run(hall_of_meat_scores_a_bail);
+2 -1
View File
@@ -17,7 +17,7 @@ namespace {
struct Event {
std::string_view name, permission;
};
constexpr std::array<Event, 29> events{{
constexpr std::array<Event, 31> events{{
{"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}},
{"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"},
{"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"},
@@ -26,6 +26,7 @@ constexpr std::array<Event, 29> events{{
{"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"},
{"physics_step", "events.physics"}, {"launch", "events.skater"}, {"trick_notes", "events.tricks"},
{"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, {"camera_cycle", "camera"},
{"poll_answer", "server"}, {"poll_closed", "server"},
}};
constexpr std::size_t maximum_key = 128;
constexpr std::size_t maximum_command = 48;
+5 -1
View File
@@ -282,7 +282,11 @@ struct UiApi {
auto& plugin = plugin_of(L);
need(L, plugin, "overlay", "world_to_screen");
const auto on = plugin.services_.world_to_screen(point(L, 2, "the world position"));
if (on) lua_pushvector(L, (*on)[0], (*on)[1], (*on)[2]);
// A point just in front of the camera and off to the side lands millions of pixels out,
// where the drawing functions refuse it; nil here, as for a point behind the camera, so
// a plugin that draws what it gets back keeps running.
const auto drawable = [](float v) { return std::isfinite(v) && std::fabs(v) <= 1e6f; };
if (on && drawable((*on)[0]) && drawable((*on)[1]) && drawable((*on)[2])) lua_pushvector(L, (*on)[0], (*on)[1], (*on)[2]);
else lua_pushnil(L);
return 1;
}
+78 -1
View File
@@ -2063,6 +2063,83 @@ struct WorldApi {
auto& plugin = authority(L, "reskate.server.unban");
return result(L, game(plugin).unban(player_of(L, 1)));
}
// ---- cards and polls on players' screens ---------------------------------------------------------
// Who a card or poll is for: "all" (empty: everyone on the server), one player (an id or a player
// table) or a list of them, 1 to 128.
static std::vector<std::uint64_t> players_of(lua_State* L, int index) {
if (lua_isstring(L, index) && std::string_view(lua_tostring(L, index)) == "all") return {};
if (!lua_istable(L, index)) return {player_of(L, index)};
lua_getfield(L, index, "id");
const bool one = !lua_isnil(L, -1);
lua_pop(L, 1);
if (one) return {player_of(L, index)};
const int count = lua_objlen(L, index);
if (count < 1 || count > 128) luaL_argerror(L, index, "a player, a list of 1 to 128 players, or \"all\"");
std::vector<std::uint64_t> players;
for (int i = 1; i <= count; ++i) {
lua_rawgeti(L, index, i);
const auto id = player_of(L, lua_gettop(L));
if (std::find(players.begin(), players.end(), id) == players.end()) players.push_back(id);
lua_pop(L, 1);
}
return players;
}
// As the roster carries an announcement and a vote (Extension/Multiplayer/Net/protocol.h:
// max_vote_label, max_vote_answers, max_vote_answer).
static constexpr std::size_t card_text = 120, poll_answers = 6, poll_answer = 48;
static std::string line_of(lua_State* L, int index, std::size_t most, const char* what) {
auto text = checked_text(L, index, most, what);
if (text.empty()) luaL_error(L, "%s is empty", what);
return text;
}
// server.card(players, text, seconds?): up for 1 to 60 s (none: long enough to read).
static int server_card(lua_State* L) {
auto& plugin = authority(L, "reskate.server.card");
const auto players = players_of(L, 1);
const auto text = line_of(L, 2, card_text, "a card's text");
const auto seconds = lua_isnoneornil(L, 3) ? 0 : luaL_checkinteger(L, 3);
if (!lua_isnoneornil(L, 3) && (seconds < 1 || seconds > 60)) luaL_argerror(L, 3, "seconds, 1 to 60 (none: long enough to read)");
return result(L, game(plugin).server_card(plugin.id(), players, text, static_cast<unsigned>(seconds)));
}
// server.poll(players, question, answers, {seconds}?): the poll's id, or nil and why. 2 to 6
// answers; it runs 5 to 300 s (none: the server's poll time).
static int server_poll(lua_State* L) {
auto& plugin = authority(L, "reskate.server.poll");
const auto players = players_of(L, 1);
const auto question = line_of(L, 2, card_text, "a poll's question");
luaL_checktype(L, 3, LUA_TTABLE);
const int count = lua_objlen(L, 3);
if (count < 2 || count > static_cast<int>(poll_answers)) luaL_argerror(L, 3, "2 to 6 answers");
std::vector<std::string> answers;
for (int i = 1; i <= count; ++i) {
lua_rawgeti(L, 3, i);
answers.push_back(line_of(L, lua_gettop(L), poll_answer, "a poll's answer"));
lua_pop(L, 1);
}
lua_Integer seconds = 0;
if (!lua_isnoneornil(L, 4)) {
luaL_checktype(L, 4, LUA_TTABLE);
if (lua_getfield(L, 4, "seconds") != LUA_TNIL) {
seconds = luaL_checkinteger(L, -1);
if (seconds < 5 || seconds > 300) luaL_argerror(L, 4, "seconds, 5 to 300 (none: the server's poll time)");
}
lua_pop(L, 1);
}
std::uint32_t id = 0;
if (const auto why = game(plugin).server_poll(plugin.id(), players, question, answers, static_cast<unsigned>(seconds), id); !why.empty()) {
lua_pushnil(L);
lua_pushlstring(L, why.data(), why.size());
return 2;
}
lua_pushnumber(L, static_cast<double>(id));
return 1;
}
static int server_poll_close(lua_State* L) {
auto& plugin = authority(L, "reskate.server.poll_close");
const double id = luaL_checknumber(L, 1);
if (!(id >= 1 && id <= 4294967295.0) || id != std::floor(id)) luaL_argerror(L, 1, "a poll's id");
return result(L, game(plugin).server_poll_close(plugin.id(), static_cast<std::uint32_t>(id)));
}
// ---- server.parks: a dedicated server's park mods -----------------------------------------------
// parks(): {name, map, here (fits the map running now), spawned, objects, minutes_left}[];
// park_spawn(name, minutes?): for that long (1-1440), or until removed; park_remove(name).
@@ -2815,7 +2892,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}});
add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move},
{"ban", server_ban}, {"unban", server_unban}, {"parks", server_parks}, {"park_spawn", server_park_spawn},
{"park_remove", server_park_remove}});
{"park_remove", server_park_remove}, {"card", server_card}, {"poll", server_poll}, {"poll_close", server_poll_close}});
add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting},
{"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}});
add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous},
+104
View File
@@ -0,0 +1,104 @@
#include "plugin_content.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
#include <fstream>
#include <sstream>
namespace dingosdk::plugins::content {
namespace {
// The names mod_list.h's scan_mods takes for a mod folder, as long as read_content takes.
bool mod_name(std::string_view name) {
if (name.empty() || name.size() > 64 || name.front() == '.') return false;
return std::all_of(name.begin(), name.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ' ';
});
}
bool same_name(std::string_view a, std::string_view b) {
return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) {
const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c + 32) : c; };
return lower(x) == lower(y);
});
}
// manifest.json's "name", 1 to 64 characters, or empty when there is none to read.
std::string manifest_name(const std::filesystem::path& directory) {
const auto file = directory / "manifest.json";
std::error_code error;
if (!std::filesystem::is_regular_file(file, error) || std::filesystem::file_size(file, error) > 32 * 1024 || error) return {};
try {
std::ifstream in(file, std::ios::binary);
std::stringstream text;
text << in.rdbuf();
const auto root = Json::parse(text.str());
if (!root.is_object() || !root.contains("name") || !root.at("name").is_string()) return {};
const auto& name = root.at("name").string();
return name.size() <= 64 ? name : std::string{};
} catch (const std::exception&) {
return {};
}
}
} // namespace
std::vector<Mod> mod_folders(const std::filesystem::path& mods_root) {
std::vector<Mod> mods;
std::error_code error;
for (std::filesystem::directory_iterator it(mods_root, error), end; !error && it != end; it.increment(error)) {
std::error_code entry;
if (!it->is_directory(entry)) continue;
const auto utf8 = it->path().filename().u8string();
std::string name(utf8.begin(), utf8.end());
if (!mod_name(name)) continue;
auto title = manifest_name(it->path());
mods.push_back({name, title.empty() ? name : std::move(title), it->path()});
}
std::sort(mods.begin(), mods.end(), [](const Mod& a, const Mod& b) { return a.name < b.name; });
return mods;
}
void list(const std::vector<Mod>& mods, std::string_view folder, std::vector<Game::ContentFile>& files) {
for (const auto& mod : mods) {
const auto root = mod.directory / std::filesystem::path(std::string(folder));
std::error_code error;
if (!std::filesystem::is_directory(root, error)) continue;
std::vector<Game::ContentFile> found;
for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end;
!error && it != end; it.increment(error)) {
if (it.depth() >= 4) it.disable_recursion_pending();
if (!it->is_regular_file(error)) continue;
const auto relative = it->path().lexically_relative(root).generic_wstring();
std::string path;
bool plain = true;
for (const auto c : relative) {
if (c < 0x20 || c > 0x7e) plain = false;
path.push_back(static_cast<char>(c));
}
if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again
found.push_back({mod.name, mod.title, std::move(path), static_cast<std::uint64_t>(it->file_size(error))});
if (files.size() + found.size() >= most_files) break;
}
std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; });
for (auto& file : found) files.push_back(std::move(file));
if (files.size() >= most_files) break;
}
}
std::string read(const std::vector<Mod>& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text) {
const auto found = std::find_if(mods.begin(), mods.end(), [&](const Mod& each) { return same_name(each.name, mod); });
if (found == mods.end()) return "no such mod is on";
const auto root = found->directory / std::filesystem::path(std::string(folder));
const auto file = root / std::filesystem::path(std::string(path));
std::error_code error;
const auto real_root = std::filesystem::weakly_canonical(root, error);
const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error);
if (error) return "not found";
const auto relative = real_file.lexically_relative(real_root);
if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder";
if (!std::filesystem::is_regular_file(file, error)) return "not found";
const auto size = std::filesystem::file_size(file, error);
if (error) return "the file cannot be read";
if (size > most_bytes) return "the file is larger than 1 MB";
std::ifstream in(file, std::ios::binary);
text.resize(static_cast<std::size_t>(size));
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
return {};
}
} // namespace dingosdk::plugins::content
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "plugin_game.h"
#include <cstddef>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
// mods.content and read_content, from the mods' folders on disk: the game reads its enabled mods
// (plugin_game_runtime.cpp), a dedicated server every mod folder in its Mods (server_plugins.cpp).
// Both keep the same limits, so a plugin reads the same files on either.
namespace dingosdk::plugins::content {
struct Mod {
std::string name, title; // the folder's name and the mod's own name
std::filesystem::path directory;
};
inline constexpr std::size_t most_files = 1000, most_bytes = 1u << 20;
// Every folder in `mods_root` a mod can be, by name: not hidden (".reskate_plugin_data"), and a
// name a plugin can ask for again. Titled by its manifest.json's "name", else its folder's name.
std::vector<Mod> mod_folders(const std::filesystem::path& mods_root);
// The files under <mod>/<folder>/ of each of `mods` in turn, sorted within a mod, four folders deep
// at most and most_files in all; `folder` is checked against the manifest already.
void list(const std::vector<Mod>& mods, std::string_view folder, std::vector<Game::ContentFile>& files);
// One of them, at most most_bytes; `path` is checked already (safe_relative_path). Empty when read,
// else why not.
std::string read(const std::vector<Mod>& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text);
} // namespace dingosdk::plugins::content
+26
View File
@@ -617,6 +617,32 @@ public:
virtual std::vector<ServerPark> server_parks() { return {}; }
virtual std::string spawn_park(std::string_view /*plugin*/, std::string_view /*name*/, unsigned /*minutes*/) { return unavailable; }
virtual std::string remove_park(std::string_view /*plugin*/, std::string_view /*name*/) { return unavailable; }
// Cards and polls on players' screens (a dedicated server's only): each player's own, shown over
// the server's announcement or vote until it ends, then the server's again. `players` empty is
// everyone on the server now. A card is up for `seconds` (0: long enough to read). A poll has
// 2 to max_vote_answers answers and runs `seconds` (0: the server's poll time); it is one card
// with one `id` for all its players, and closes for each when they answer. A newer poll for a
// player replaces theirs. What each player did comes back from take_poll_news.
virtual std::string server_card(std::string_view /*plugin*/, const std::vector<std::uint64_t>& /*players*/, std::string_view /*text*/,
unsigned /*seconds*/) {
return unavailable;
}
virtual std::string server_poll(std::string_view /*plugin*/, const std::vector<std::uint64_t>& /*players*/, std::string_view /*question*/,
const std::vector<std::string>& /*answers*/, unsigned /*seconds*/, std::uint32_t& /*id*/) {
return unavailable;
}
// Closes a poll of the plugin's for everyone it is still open for.
virtual std::string server_poll_close(std::string_view /*plugin*/, std::uint32_t /*id*/) { return unavailable; }
// A player answered a plugin's poll (`answer` from 1, its text), or it closed for them unanswered
// (`answer` 0, and why: "timeout", "left", "replaced" or "closed").
struct PollNews {
std::string plugin;
std::uint32_t id{};
std::uint64_t player{};
std::size_t answer{};
std::string text, why;
};
virtual std::vector<PollNews> take_poll_news() { return {}; }
// ---- 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.
+6 -53
View File
@@ -1,5 +1,6 @@
#include "plugin_game_runtime.h"
#include "plugin_session_feed.h"
#include "plugin_content.h"
#include "Runtime/runtime_internal.h"
#include "plugin_ui.h"
#include "Engine/Game/World/park_rotation.h"
@@ -1443,13 +1444,9 @@ public:
tricks::prepare_trick_gestures();
return tricks::trick_gesture(trick);
}
// ---- content: <mod>/<folder>/ of every enabled mod ---------------------------------------------
// ---- content: <mod>/<folder>/ of every enabled mod (plugin_content.h) --------------------------
// The enabled mods, read again at most every 2 s: a page may list them every frame.
struct EnabledMod {
std::string name, title;
std::filesystem::path directory;
};
std::vector<EnabledMod> enabled_mods() {
std::vector<content::Mod> enabled_mods() {
const auto now = GetTickCount64();
if (enabled_read_at_ && now - enabled_read_at_ < 2000) return enabled_mods_;
enabled_read_at_ = now;
@@ -1460,56 +1457,12 @@ public:
if (entry.enabled) enabled_mods_.push_back({entry.mod.name, entry.mod.title.empty() ? entry.mod.name : entry.mod.title, entry.mod.directory});
return enabled_mods_;
}
static constexpr std::size_t most_content_files = 1000, most_content_bytes = 1u << 20;
std::string content_list(std::string_view folder, std::vector<ContentFile>& files) override {
for (const auto& mod : enabled_mods()) {
const auto root = mod.directory / std::filesystem::path(std::string(folder));
std::error_code error;
if (!std::filesystem::is_directory(root, error)) continue;
std::vector<ContentFile> found;
for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end;
!error && it != end; it.increment(error)) {
if (it.depth() >= 4) it.disable_recursion_pending();
if (!it->is_regular_file(error)) continue;
const auto relative = it->path().lexically_relative(root).generic_wstring();
std::string path;
bool plain = true;
for (const auto c : relative) {
if (c < 0x20 || c > 0x7e) plain = false;
path.push_back(static_cast<char>(c));
}
if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again
found.push_back({mod.name, mod.title, std::move(path), static_cast<std::uint64_t>(it->file_size(error))});
if (files.size() + found.size() >= most_content_files) break;
}
std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; });
for (auto& file : found) files.push_back(std::move(file));
if (files.size() >= most_content_files) break;
}
content::list(enabled_mods(), folder, files);
return {};
}
std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string& text) override {
const auto mods_now = enabled_mods();
const auto found = std::find_if(mods_now.begin(), mods_now.end(), [&](const EnabledMod& each) {
return each.name.size() == mod.size() && _strnicmp(each.name.c_str(), mod.data(), mod.size()) == 0;
});
if (found == mods_now.end()) return "no such mod is on";
const auto root = found->directory / std::filesystem::path(std::string(folder));
const auto file = root / std::filesystem::path(std::string(path));
std::error_code error;
const auto real_root = std::filesystem::weakly_canonical(root, error);
const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error);
if (error) return "not found";
const auto relative = real_file.lexically_relative(real_root);
if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder";
if (!std::filesystem::is_regular_file(file, error)) return "not found";
const auto size = std::filesystem::file_size(file, error);
if (error) return "the file cannot be read";
if (size > most_content_bytes) return "the file is larger than 1 MB";
std::ifstream in(file, std::ios::binary);
text.resize(static_cast<std::size_t>(size));
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
return {};
return content::read(enabled_mods(), mod, folder, path, text);
}
std::string trick_label(std::string_view plugin, std::string_view from, std::string_view to) override {
return trick_rewrite::label(plugin, from, to);
@@ -1742,7 +1695,7 @@ private:
std::vector<std::string> installed;
};
std::map<std::string, VariantRead, std::less<>> variant_reads_;
std::vector<EnabledMod> enabled_mods_;
std::vector<content::Mod> enabled_mods_;
std::string reacher_; // the plugin holding the skater's arm (skater_reach)
std::string poser_; // the plugin turning the skater's joints (skater_pose)
std::string carrier_; // the plugin having the skater carry something (skater_carry)
+14
View File
@@ -383,6 +383,20 @@ void Host::follow_game() {
each([&](Plugin& plugin, const std::string&) { plugin.throwdown_phase(active ? "started" : "ended"); });
}
}
// Answers to a server copy's polls (server.poll), and polls that closed unanswered, to the
// plugin that asked.
for (const auto& news : game.take_poll_news()) {
auto data = Json::object();
data["id"] = static_cast<double>(news.id);
data["player"] = std::to_string(news.player);
if (news.answer) {
data["answer"] = static_cast<double>(news.answer);
data["text"] = news.text;
} else data["why"] = news.why;
each([&](Plugin& plugin, const std::string&) {
if (plugin.id() == news.plugin) plugin.game_event(news.answer ? "poll_answer" : "poll_closed", "server", data);
});
}
for (const auto& [plugin, action] : game.take_presses())
if (auto* entry = find(plugin); entry && entry->plugin) {
entry->plugin->action(action);
+109
View File
@@ -138,6 +138,18 @@ struct Player {
p.world_ready = true;
wire().inbound.push_back({id, encode_wire(p), now});
}
void chat(std::uint64_t now, const std::string &text) {
auto p = header(PacketKind::chat, now);
p.text = text;
wire().inbound.push_back({id, encode_wire(p), now});
}
// The latest roster the server sent this player (and forgets what else it sent).
std::optional<Packet> roster() {
std::optional<Packet> latest;
for (const auto &bytes : std::exchange(wire().sent[id], {}))
if (auto p = decode_wire(bytes); p && p->kind == PacketKind::roster) latest = std::move(p);
return latest;
}
void pose(std::uint64_t now) {
for (const auto &bytes : std::exchange(wire().sent[id], {}))
if (const auto read = pose_batch::Ack::read(bytes)) upload.ack(*read);
@@ -213,6 +225,10 @@ int run(const std::filesystem::path &fixtures) {
local ok, why = pcall(reskate.server.parks)
reskate.console.print(ok and "blind listed parks" or "blind parks refused: " .. tostring(why))
end)
reskate.console.command("blind content", "mod content without mods.read", function()
local ok, why = pcall(reskate.mods.content, "parks")
reskate.console.print(ok and "blind listed content" or "blind content refused: " .. tostring(why))
end)
)");
// One that says what the level is, and when it changes.
@@ -279,6 +295,40 @@ int run(const std::filesystem::path &fixtures) {
end)
)");
// One that reads the parks folder of the mods the server has, as a game's plugin reads its
// enabled mods'.
write(root / "Plugins" / "reader" / "reskate-plugin.json",
R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "mods.read"], "content": ["parks"]})");
write(root / "Plugins" / "reader" / "plugin" / "main.lua", R"(
reskate.console.command("reader", "mod content, for the test", function()
for _, f in ipairs(assert(reskate.mods.content("parks"))) do
reskate.console.print(string.format("content %s (%s) %s %d", f.mod, f.title, f.path, f.size))
end
local text = assert(reskate.mods.read_content("mixed park", "parks", "grom.park.json"))
reskate.console.print("read " .. #text .. " " .. tostring(text:find('"reskate-park"', 1, true) ~= nil))
local missing, why = reskate.mods.read_content("mixed park", "parks", "nothing.json")
reskate.console.print("missing " .. tostring(missing) .. " " .. why)
end)
)");
// One that asks players and shows them cards, and says what came back.
write(root / "Plugins" / "asker" / "reskate-plugin.json",
R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server"]})");
write(root / "Plugins" / "asker" / "plugin" / "main.lua", R"(
reskate.on("poll_answer", function(e) reskate.console.print(string.format("answer %s %d %s", e.player, e.answer, e.text)) end)
reskate.on("poll_closed", function(e) reskate.console.print(string.format("closed %s %s", e.player, e.why)) end)
reskate.console.command("asker", "cards and polls, for the test", function(args)
local who = args[2] == "all" and "all" or args[2]
if args[1] == "poll" then
local id, why = reskate.server.poll(who, "Next track?", {"Docks", "Hill"}, {seconds = tonumber(args[3]) or 30})
reskate.console.print("poll " .. tostring(id or why))
elseif args[1] == "card" then
local ok, why = reskate.server.card(who, "Race in 30 s", 5)
reskate.console.print("card " .. tostring(ok or why))
end
end)
)");
std::vector<std::string> log;
SteamTransport transport;
Host host(config, transport, [&](const std::string &line) { log.push_back(line); });
@@ -390,6 +440,60 @@ int run(const std::filesystem::path &fixtures) {
plugin_command("blind parks");
check(has(log, "blind parks refused: ") && has(log, "server.parks"), "A plugin without server.parks listed the parks");
// The mods the server has, read through mods.content as a game reads its enabled mods.
const auto size_of = [&](const char *mod, const char *file) {
return std::to_string(std::filesystem::file_size(root / "Mods" / mod / "parks" / file));
};
plugin_command("reader");
check(has(log, "content mixed park (mixed park) grom.park.json " + size_of("mixed park", "grom.park.json")) &&
has(log, "content popular skate 2 street park (popular skate 2 street park) bam.park.json " +
size_of("popular skate 2 street park", "bam.park.json")),
"The server's mods' parks folders were not listed");
check(has(log, "read " + size_of("mixed park", "grom.park.json") + " true"), "A park mod's file was not read");
check(has(log, "missing nil not found"), "A missing file was not \"not found\"");
plugin_command("blind content");
check(has(log, "blind content refused: ") && has(log, "mods.read"), "A plugin without mods.read listed the mods' content");
// Cards and polls: each player's own roster carries them, over the server's; a player's answer
// (as the game sends it, "/2") comes back to the plugin, and so does a poll that ran out.
const auto first_id = std::to_string(players[0].id), second_id = std::to_string(players[1].id);
for (auto &p : players) p.roster();
plugin_command("asker card " + first_id);
check(has(log, "card true"), "The plugin could not show a card");
plugin_command("asker poll all 10");
check(has(log, "poll ") && !has(log, "poll nil"), "The plugin could not ask everyone");
run_for(200000);
auto first_roster = players[0].roster(), second_roster = players[1].roster();
check(first_roster && first_roster->announcement.text == "Race in 30 s" && first_roster->announcement.seconds == 5,
"The first player's roster has no card");
check(second_roster && second_roster->announcement.text.empty(), "The second player was shown the first player's card");
for (auto *r : {&first_roster, &second_roster})
check(*r && (*r)->vote.label == "Next track?" && (*r)->vote.answers == std::vector<std::string>{"Docks", "Hill"} &&
(*r)->vote.kind == server_vote_poll && (*r)->vote.outcome == vote_running && (*r)->vote.seconds == 10,
"A player's roster has no poll");
const auto poll_id = first_roster ? first_roster->vote.id : 0;
check(poll_id && second_roster && second_roster->vote.id == poll_id, "The poll is not one for everyone");
log.clear();
players[0].chat(now, "/2");
run_for(200000);
check(has(log, "answer " + first_id + " 2 Hill"), "The plugin did not hear the answer");
first_roster = players[0].roster();
check(first_roster && first_roster->vote.id != poll_id, "The answered poll stayed on the player's screen");
players[0].chat(now, "/1");
run_for(200000);
check(!has(log, "answer " + first_id + " 1"), "A poll was answered twice");
log.clear();
run_for(10000000);
check(has(log, "closed " + second_id + " timeout") && !has(log, "closed " + first_id), "The unanswered poll did not run out");
second_roster = players[1].roster();
check(second_roster && second_roster->vote.id != poll_id && second_roster->announcement.text.empty(), "The poll stayed after it ran out");
plugin_command("asker poll " + second_id);
plugin_command("asker poll " + second_id);
run_for(200000);
check(has(log, "closed " + second_id + " replaced"), "A new poll did not replace the player's earlier one");
plugin_command("asker poll 76561198000000099");
check(has(log, "poll that player is not on the server"), "A poll for nobody here was asked");
// 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; });
@@ -403,6 +507,11 @@ 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 player who leaves with a poll open: the plugin hears it closed.
log.clear();
host.drop(players[1].id, "testing");
run_for(200000);
check(has(log, "closed " + second_id + " left"), "A poll stayed open for a player who left");
if (failures)
for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str());
return failures ? 1 : 0;
+12 -3
View File
@@ -823,11 +823,13 @@ void Host::send_roster() {
if (m.party && std::none_of(p.members.begin(), p.members.end(),
[&](const Member &o) { return o.party == m.party && o.party_leader; }))
m.party_leader = true; // the first listed member of a party missing its leader
// An announcement for one player: everyone else's roster goes without it. And a player with
// locked tools is told those are off for them. Everyone else gets the same roster; players
// whose roster differs in the same way share one.
// An announcement for one player: everyone else's roster goes without it. A player with
// locked tools is told those are off for them, and one with a plugin's card or poll is shown
// it (by its id) over the server's. Everyone else gets the same roster; players whose roster
// differs in the same way share one.
struct Variant {
bool noclip{}, no_bail{}, boosts{}, ollies{}, enforce_tuning{}, announcement{};
std::uint32_t plugin_card{}, plugin_poll{};
auto operator<=>(const Variant &) const = default;
};
const Variant common{config_.noclip, config_.no_bail, config_.boosts, true, config_.enforce_tuning, !announcement_for_};
@@ -837,6 +839,8 @@ void Host::send_roster() {
if (!guest->handshaken) continue;
auto variant = common;
variant.announcement = !announcement_for_ || id == announcement_for_;
if (const auto card = plugin_cards_.find(id); card != plugin_cards_.end()) variant.plugin_card = card->second.card.id;
if (const auto poll = plugin_polls_.find(id); poll != plugin_polls_.end()) variant.plugin_poll = poll->second.id;
if (const auto locks = locks_of(id)) {
// Without "movement" (send_permissions), the tools their role gives are said here instead.
const bool movement = (locks & lock_movement) && has(id, multiplayer::role_movement);
@@ -853,6 +857,7 @@ void Host::send_roster() {
others.insert(id);
}
const auto shown = p.announcement;
const auto vote = p.vote;
if (announcement_for_) p.announcement = {};
broadcast(p, true, false, 0, nullptr, others.empty() ? nullptr : &others);
for (const auto &[variant, players] : variants) {
@@ -862,6 +867,9 @@ void Host::send_roster() {
p.guest_ollies = variant.ollies;
p.enforce_tuning = variant.enforce_tuning;
p.announcement = variant.announcement ? shown : multiplayer::ServerAnnouncement{};
p.vote = vote;
// The same card and poll ids: the same card and poll, so any of these players' is theirs.
if (variant.plugin_card || variant.plugin_poll) plugin_screens_roster(*players.begin(), p.announcement, p.vote);
broadcast(p, true, false, 0, &players);
}
roster_dirty_ = false;
@@ -1031,6 +1039,7 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string &
guest->sound_sender.forget(id);
}
party_left(id, name);
plugin_screens_left(id);
// Their vote cooldown stays (tick() drops it once it has run out): leaving and coming
// back does not let a player start another vote sooner.
// One voter fewer, or the player a kick vote was about. Counted in tick(): a passing kick
+48
View File
@@ -23,6 +23,7 @@
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <array>
@@ -112,6 +113,26 @@ class Host {
std::string plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes);
// A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0).
void send_plugin_message(std::uint64_t to, const std::vector<std::uint8_t> &bytes);
// Cards and polls for the server's plugins (server_votes.cpp), on the players' screens: each
// player's own, shown over the server's announcement and vote until it ends, then those again.
// `players` empty is everyone on now. A card is up `seconds` (0: as long as an announcement);
// a poll runs `seconds` (0: votes.poll_seconds), one id for all its players, closed for each
// as they answer ("/2"); a player's newer poll replaces theirs. "" when shown, else why not.
std::string plugin_card(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view text, unsigned seconds);
std::string plugin_poll(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view question,
const std::vector<std::string> &answers, unsigned seconds, std::uint32_t &id);
std::string plugin_poll_close(std::string_view plugin, std::uint32_t id);
void plugin_screens_release(std::string_view plugin); // a plugin that stopped: its cards and polls go
// A player's answer (from 1) to a plugin's poll, or (answer 0) the poll closed for them and why:
// "timeout", "left", "replaced" or "closed".
struct PollNews {
std::string plugin;
std::uint32_t id{};
std::uint64_t player{};
std::size_t answer{};
std::string text, why;
};
std::vector<PollNews> take_poll_news() { return std::exchange(poll_news_, {}); }
enum class VoteKind { map, kick, time, custom, poll, extend };
@@ -296,6 +317,22 @@ class Host {
std::uint64_t announcement_until_{};
std::uint64_t announcement_for_{}; // the one player shown it, or 0: everyone
std::uint32_t announcement_ids_{};
// The plugins' cards and polls, by the player they are shown to (plugin_card, plugin_poll).
struct PluginCard {
std::string plugin;
multiplayer::ServerAnnouncement card;
std::uint64_t until{};
};
struct PluginPoll {
std::string plugin;
std::uint32_t id{};
std::string question;
std::vector<std::string> answers;
std::uint64_t ends{};
};
std::map<std::uint64_t, PluginCard> plugin_cards_;
std::map<std::uint64_t, PluginPoll> plugin_polls_;
std::vector<PollNews> poll_news_;
std::uint64_t announced_at_{}; // the last timed announcement, or while nobody is on
std::size_t next_announcement_{};
void active(Guest &guest) {
@@ -520,6 +557,17 @@ class Host {
// The announcement card on every player's screen, or only on player `to`'s (not in chat).
void announce(std::string_view text, std::uint64_t to = 0);
void tick_announcements(); // the owner's messages in turn, on their timer
// The plugins' cards and polls (plugin_card, plugin_poll): a poll closed for one player, with
// why; a number they typed, true when it was about their plugin poll (answered or refused);
// what ran out; a player who left; and the card and vote player `id`'s roster carries instead
// of `card` and `vote`.
// The players a plugin named who are on now (everyone for none); empty, and why, when none is.
std::vector<Guest *> plugin_screen_players(const std::vector<std::uint64_t> &players, std::string &why);
void close_plugin_poll(std::uint64_t player, std::string_view why);
bool answer_plugin_poll(Guest &, std::size_t answer);
void tick_plugin_screens();
void plugin_screens_left(std::uint64_t player);
void plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const;
void reply(Guest &, std::string_view text, unsigned max_lines = 12);
Guest *match_player(std::string_view text);
// Sends each mover to a ring of `radius` around `at`, 1 m up, so nobody lands inside anyone; how many were sent.
+36
View File
@@ -1,5 +1,6 @@
#include "server_plugins.h"
#include "server_host.h"
#include "Extension/Plugins/plugin_content.h"
#include "Extension/Plugins/plugin_host.h"
#include "Extension/Plugins/plugin_manifest.h"
#include "Extension/Plugins/plugin_wire.h"
@@ -96,6 +97,21 @@ class ServerGame final : public Game {
return host_.plugin_park(plugin, true, name, minutes);
}
std::string remove_park(std::string_view plugin, std::string_view name) override { return host_.plugin_park(plugin, false, name, 0); }
// Cards and polls on the players' screens, as the roster carries the server's own.
std::string server_card(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view text, unsigned seconds) override {
return host_.plugin_card(plugin, players, text, seconds);
}
std::string server_poll(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view question,
const std::vector<std::string> &answers, unsigned seconds, std::uint32_t &id) override {
return host_.plugin_poll(plugin, players, question, answers, seconds, id);
}
std::string server_poll_close(std::string_view plugin, std::uint32_t id) override { return host_.plugin_poll_close(plugin, id); }
std::vector<PollNews> take_poll_news() override {
std::vector<PollNews> news;
for (auto &n : host_.take_poll_news()) news.push_back({std::move(n.plugin), n.id, n.player, n.answer, std::move(n.text), std::move(n.why)});
return news;
}
void release(std::string_view plugin) override { host_.plugin_screens_release(plugin); }
// The skaters of the players in the world, as their latest poses put them. The server has
// no skater of its own, so this is every player's.
std::vector<PeerSkater> peer_skaters() override {
@@ -108,6 +124,15 @@ class ServerGame final : public Game {
}
return result;
}
// mods.content: every mod folder in the server's Mods, as a game reads its enabled mods. The
// server has no load order or mods.json of its own; the folders come by name, as for its maps.
std::string content_list(std::string_view folder, std::vector<ContentFile> &files) override {
content::list(mod_folders(), folder, files);
return {};
}
std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string &text) override {
return content::read(mod_folders(), mod, folder, path, text);
}
// 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));
@@ -123,6 +148,15 @@ class ServerGame final : public Game {
}
private:
// Read again at most every 2 s: a plugin may list them every tick.
const std::vector<content::Mod> &mod_folders() {
const auto now = std::chrono::steady_clock::now();
if (!mods_read_ || now - *mods_read_ >= std::chrono::seconds(2)) {
mods_read_ = now;
mods_ = content::mod_folders(host_.config().mods);
}
return mods_;
}
bool present(std::uint64_t player) const {
for (const auto &p : host_.player_list())
if (p.id == player) return true;
@@ -130,6 +164,8 @@ class ServerGame final : public Game {
}
Host &host_;
ServerPlugins::Log log_;
std::vector<content::Mod> mods_;
std::optional<std::chrono::steady_clock::time_point> mods_read_;
};
class ServerEnvironment final : public Environment {
+2 -1
View File
@@ -12,7 +12,8 @@
// 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) and its storage.
// 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).
// Everything here runs on the server's tick thread.
namespace dingosdk::server {
class Host;
+148 -1
View File
@@ -155,6 +155,12 @@ void Host::chat_command(Guest &guest, std::string_view line) {
const std::string whisper = "/w <player> <message>: send a private message";
return reply(guest, (text.empty() ? "This server has no player votes. Type /tp <player> to teleport.\n" : text) + whisper);
}
// A number from a player with a plugin's poll open answers that one, not the server's: it is the
// plugin's question, so their role's "votes" is not asked.
if (plugin_polls_.contains(guest.member.id)) {
const auto word = verb == "vote" ? lower(split(rest).first) : verb;
if (word.size() == 1 && word[0] >= '1' && word[0] <= '9' && answer_plugin_poll(guest, static_cast<std::size_t>(word[0] - '1'))) return;
}
if ((verb == "party" || verb == "p") && !may(guest.member.id, multiplayer::role_parties))
return reply(guest, role_refusal(guest.member.id, "use parties"));
if ((verb == "w" || verb == "whisper" || verb == "tell") && !may(guest.member.id, multiplayer::role_whisper))
@@ -405,11 +411,14 @@ void Host::answer_poll(Guest &guest, std::size_t answer) {
}
bool Host::answers_vote(const Guest &guest, std::string_view line) const {
if (!vote_) return false;
// "/2", "/yes", or the same after "/vote", as chat_command takes them.
const auto [first, rest] = split(line);
auto verb = lower(first);
if (verb == "vote") verb = lower(split(rest).first);
if (const auto poll = plugin_polls_.find(guest.member.id);
poll != plugin_polls_.end() && verb.size() == 1 && verb[0] >= '1' && static_cast<std::size_t>(verb[0] - '0') <= poll->second.answers.size())
return true;
if (!vote_) return false;
if (vote_->kind == VoteKind::poll)
return verb.size() == 1 && verb[0] >= '1' && static_cast<std::size_t>(verb[0] - '0') <= vote_->answers.size();
return guest.member.id != vote_->target && (verb == "yes" || verb == "y" || verb == "no" || verb == "n");
@@ -684,6 +693,7 @@ void Host::announce(std::string_view text, std::uint64_t to) {
roster_dirty_ = true;
}
void Host::tick_announcements() { // the timer waits while nobody is on
tick_plugin_screens();
if (announcement_.id && now_ >= announcement_until_) {
announcement_ = {};
announcement_for_ = 0;
@@ -699,6 +709,143 @@ void Host::tick_announcements() { // the timer waits while nobody is on
next_announcement_ %= a.messages.size();
announce(a.messages[next_announcement_++]);
}
// ---- The plugins' cards and polls ------------------------------------------------------------------
std::vector<Host::Guest *> Host::plugin_screen_players(const std::vector<std::uint64_t> &players, std::string &why) {
std::vector<Guest *> found;
for (auto &[id, guest] : guests_)
if (guest->handshaken && (players.empty() || std::find(players.begin(), players.end(), id) != players.end())) found.push_back(guest.get());
if (found.empty())
why = players.empty() ? "nobody is on the server" : players.size() == 1 ? "that player is not on the server" : "none of those players is on the server";
return found;
}
std::string Host::plugin_card(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view text, unsigned seconds) {
const auto line = clean_chat_text(text);
if (line.empty()) return "the card has nothing to show";
std::string why;
const auto shown = plugin_screen_players(players, why);
if (shown.empty()) return why;
// As long as an announcement of the same text, unless the plugin says.
if (!seconds) seconds = static_cast<unsigned>(std::clamp<std::size_t>(5 + line.size() / 20, 6, 15));
if (!++announcement_ids_) ++announcement_ids_;
std::vector<std::string> names;
for (const auto *guest : shown) {
plugin_cards_[guest->member.id] = {std::string(plugin), {announcement_ids_, static_cast<std::uint16_t>(seconds), line}, now_ + microseconds(seconds)};
names.push_back(guest_name(*guest));
}
log_("[card by plugin " + std::string(plugin) + " -> " + (players.empty() ? std::string("everyone") : joined(names, ", ")) + "] " + line);
roster_dirty_ = true;
return {};
}
std::string Host::plugin_poll(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view question,
const std::vector<std::string> &answers, unsigned seconds, std::uint32_t &id) {
auto label = clean_chat_text(question);
cut_text(label, multiplayer::max_vote_label);
std::vector<std::string> choices;
for (const auto &answer : answers) {
auto choice = clean_chat_text(answer);
cut_text(choice, multiplayer::max_vote_answer);
if (choice.empty()) return "an answer has nothing to show";
choices.push_back(std::move(choice));
}
if (label.empty()) return "the question has nothing to show";
if (choices.size() < 2 || choices.size() > multiplayer::max_vote_answers)
return "a poll has 2 to " + std::to_string(multiplayer::max_vote_answers) + " answers";
std::string why;
const auto shown = plugin_screen_players(players, why);
if (shown.empty()) return why;
// From the server's own count of votes: no game takes one poll for another.
if (!++vote_ids_) ++vote_ids_;
id = vote_ids_;
const auto ends = now_ + microseconds(seconds ? seconds : config_.votes.poll_seconds);
std::vector<std::string> names;
for (const auto *guest : shown) {
if (plugin_polls_.contains(guest->member.id)) close_plugin_poll(guest->member.id, "replaced");
plugin_polls_[guest->member.id] = {std::string(plugin), id, label, choices, ends};
names.push_back(guest_name(*guest));
}
log_("[poll by plugin " + std::string(plugin) + " #" + std::to_string(id) + " -> " +
(players.empty() ? std::string("everyone") : joined(names, ", ")) + "] " + label + " | " + joined(choices, " | "));
roster_dirty_ = true;
return {};
}
std::string Host::plugin_poll_close(std::string_view plugin, std::uint32_t id) {
std::vector<std::uint64_t> open;
for (const auto &[player, poll] : plugin_polls_)
if (poll.id == id && poll.plugin == plugin) open.push_back(player);
if (open.empty()) return "no poll of the plugin's with that id is open";
for (const auto player : open) close_plugin_poll(player, "closed");
return {};
}
void Host::plugin_screens_release(std::string_view plugin) {
const auto before = plugin_cards_.size() + plugin_polls_.size();
std::erase_if(plugin_cards_, [&](const auto &entry) { return entry.second.plugin == plugin; });
std::erase_if(plugin_polls_, [&](const auto &entry) { return entry.second.plugin == plugin; });
if (plugin_cards_.size() + plugin_polls_.size() != before) roster_dirty_ = true;
}
void Host::close_plugin_poll(std::uint64_t player, std::string_view why) {
const auto found = plugin_polls_.find(player);
if (found == plugin_polls_.end()) return;
if (poll_news_.size() < 4096) poll_news_.push_back({found->second.plugin, found->second.id, player, 0, {}, std::string(why)});
plugin_polls_.erase(found);
roster_dirty_ = true;
}
bool Host::answer_plugin_poll(Guest &guest, std::size_t answer) {
const auto found = plugin_polls_.find(guest.member.id);
if (found == plugin_polls_.end()) return false;
const auto &poll = found->second;
if (answer >= poll.answers.size()) {
reply(guest, "Answer with /1 to /" + std::to_string(poll.answers.size()) + ".");
return true;
}
// One answer closes it for them: the card goes, and the server's vote or poll shows again if one runs.
log_("[poll by plugin " + poll.plugin + " #" + std::to_string(poll.id) + "] " + guest_name(guest) + " answered " +
std::to_string(answer + 1) + " " + poll.answers[answer]);
if (poll_news_.size() < 4096) poll_news_.push_back({poll.plugin, poll.id, guest.member.id, answer + 1, poll.answers[answer], {}});
plugin_polls_.erase(found);
roster_dirty_ = true;
return true;
}
void Host::tick_plugin_screens() {
if (std::erase_if(plugin_cards_, [&](const auto &entry) { return now_ >= entry.second.until; })) roster_dirty_ = true;
std::vector<std::uint64_t> ended;
for (const auto &[player, poll] : plugin_polls_)
if (now_ >= poll.ends) ended.push_back(player);
for (const auto player : ended) close_plugin_poll(player, "timeout");
}
void Host::plugin_screens_left(std::uint64_t player) {
plugin_cards_.erase(player);
close_plugin_poll(player, "left");
}
void Host::plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const {
const auto left = [&](std::uint64_t until) {
return static_cast<std::uint16_t>(std::min<std::uint64_t>(until > now_ ? (until - now_ + 999999) / 1000000 : 0, 65535));
};
if (const auto found = plugin_cards_.find(id); found != plugin_cards_.end()) {
card = found->second.card;
card.seconds = left(found->second.until);
}
if (const auto found = plugin_polls_.find(id); found != plugin_polls_.end()) {
const auto &poll = found->second;
vote = {};
vote.id = poll.id;
vote.kind = multiplayer::server_vote_poll;
vote.outcome = multiplayer::vote_running;
vote.label = poll.question;
vote.answers = poll.answers;
vote.counts.assign(poll.answers.size(), 0);
vote.seconds = left(poll.ends);
}
}
std::pair<std::string, bool> Host::announcements_command(std::string_view argument) {
auto &a = config_.announcements;
const auto [what_text, rest] = split(argument);
+1
View File
@@ -147,6 +147,7 @@ add_library(dingosdk_plugins STATIC
Extension/Plugins/plugin_api_services.cpp
Extension/Plugins/plugin_api_throwdowns.cpp
Extension/Plugins/plugin_api_capture.cpp
Extension/Plugins/plugin_content.cpp
# The throwdown kit's store and the plugins' modes, which the plugin host and the game's
# native UI share (kit::flag is the game's: Extension/Throwdowns/native_flag_session.cpp).
Extension/Throwdowns/throwdown_kit.cpp