mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-26 10:26:43 +01:00
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:
commit
60cd8395d9
16 files changed
+765
-61
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user