From 45d4c64c752e7ae219247baf5f881334ae43eab9 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Thu, 22 Oct 2026 20:30:07 +0200 Subject: [PATCH] Plugins: a dedicated server's plugins see the skaters reskate.session.skaters() returned an empty list on a dedicated server: its game for plugins did not say where anyone was, though the host holds every player's latest pose. A server copy now gets each player in the world with a pose: their id, the pose's root position, its heading (read as a client reads it) and how long ago it arrived. A server plugin can now follow players itself, for a race's checkpoints or a game of tag, instead of trusting what each player's copy reports. Host::skater_poses() lists them for the plugins. dingosdk_server_plugin_tests drives the host with simulated players sending poses and checks what a server plugin sees, and that a plugin without session.skaters is still refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs7xxJDf6SDcQUaXnzmtL5 --- CMakeLists.txt | 19 ++ Server/Test/server_plugin_tests.cpp | 287 ++++++++++++++++++++++++++++ Server/server_host.cpp | 8 + Server/server_host.h | 8 + Server/server_plugins.cpp | 13 ++ 5 files changed, 335 insertions(+) create mode 100644 Server/Test/server_plugin_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f9d1e773..798d0960 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,6 +151,25 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) endif() add_test(NAME server_locks COMMAND dingosdk_server_lock_tests) + # What the server's plugins see of the server: its players' skaters. + add_executable(dingosdk_server_plugin_tests Server/Test/server_plugin_tests.cpp Server/server_host.cpp Server/server_party.cpp + Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp + $<$:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp + Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp + Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp + Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp Server/server_plugins.cpp) + target_include_directories(dingosdk_server_plugin_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_include_directories(dingosdk_server_plugin_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") + if(WIN32) + target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm + dingosdk_plugins) + else() + target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) + endif() + add_test(NAME server_plugins COMMAND dingosdk_server_plugin_tests) add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp) target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json) diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp new file mode 100644 index 00000000..416317b7 --- /dev/null +++ b/Server/Test/server_plugin_tests.cpp @@ -0,0 +1,287 @@ +// The dedicated server's plugins (server_plugins.cpp) with the server's host, over a transport +// that is not Steam's and simulated players: what a server plugin sees of the players' skaters. +// +// dingosdk_server_plugin_tests +#include "Server/server_host.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/pose_codec.h" +#include "Extension/Multiplayer/Net/wire_codec.h" +#include "Extension/Multiplayer/Steam/steam_transport.h" +#include "Engine/Game/Build/supported_build.h" +#include "Engine/Game/World/world_names.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::multiplayer { +namespace { +// What the fake transport holds: what each player is sent, and what they send. +struct Wire { + TransportStatus status; + std::vector inbound; + std::map>> sent; +}; +Wire &wire() { + static Wire value; + return value; +} +} // namespace + +struct SteamTransport::Impl {}; +SteamTransport::SteamTransport() = default; +SteamTransport::~SteamTransport() = default; +bool SteamTransport::open() { return true; } +bool SteamTransport::open_game_server(void *) { return true; } +bool SteamTransport::host(unsigned) { + wire().status.ready = wire().status.hosting = true; + wire().status.local_id = (1ULL << 56) | (4ULL << 52) | 1; // a game server's SteamID, never a player's + return true; +} +bool SteamTransport::join(std::uint64_t, std::uint32_t, std::uint16_t) { return false; } +bool SteamTransport::listen_direct(std::uint16_t) { return true; } +void SteamTransport::set_packing(unsigned) {} +std::vector SteamTransport::take_direct_notes() { return {}; } +bool SteamTransport::set_steam_debug(bool) { return false; } +bool SteamTransport::connect_peer(std::uint64_t) { return false; } +void SteamTransport::allow_peers(std::span) {} +bool SteamTransport::socket_test() { return true; } +void SteamTransport::stop() {} +void SteamTransport::disconnect(std::uint64_t id, const char *) { + std::erase_if(wire().status.peers, [&](const TransportPeer &peer) { return peer.id == id; }); +} +void SteamTransport::poll() {} +bool SteamTransport::send(std::uint64_t id, std::span bytes, bool, bool, TrafficLane) { + if (std::none_of(wire().status.peers.begin(), wire().status.peers.end(), [&](const auto &peer) { return peer.id == id; })) return false; + wire().sent[id].emplace_back(bytes.begin(), bytes.end()); + return true; +} +void SteamTransport::send_batch(std::span messages) { + for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane); +} +std::vector SteamTransport::receive() { return std::exchange(wire().inbound, {}); } +std::string SteamTransport::name(std::uint64_t) { return {}; } +const TransportStatus &SteamTransport::status() const { return wire().status; } +std::string SteamTransport::take_closed(std::uint64_t) { return {}; } +std::string SteamTransport::link_report(std::uint64_t) { return {}; } +std::int64_t SteamTransport::pending(std::uint64_t) const { return 0; } +std::string SteamTransport::relay_status() const { return "OK"; } +bool SteamTransport::set_send_rate(int) { return true; } +int SteamTransport::send_rate() const { return 900 * 1024; } +std::vector SteamTransport::links() { return {}; } +bool SteamTransport::bind(void *, void *, void *) { return true; } +} // namespace dingosdk::multiplayer + +namespace { +using namespace dingosdk; +using namespace dingosdk::multiplayer; +using namespace dingosdk::server; + +int failures{}; +void check(bool ok, const std::string &what) { + if (!ok) { + std::fprintf(stderr, "FAIL: %s\n", what.c_str()); + ++failures; + } +} +bool has(const std::vector &lines, std::string_view text) { + return std::any_of(lines.begin(), lines.end(), [&](const auto &line) { return line.find(text) != std::string::npos; }); +} +void write(const std::filesystem::path &file, const std::string &text) { + std::filesystem::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary | std::ios::trunc) << text; +} + +// One player's game: its greeting, its world being ready, and its poses sent as a game sends +// them (pose_batch.h), standing at `at` and facing `heading` about world up. +struct Player { + std::uint64_t id{}, epoch{}, secret{}, map{}; + std::string name; + std::array at{}; + float heading{}; + std::uint32_t sequence{}; + struct Kept { + std::uint32_t sequence{}; + std::uint64_t time_us{}; + pose_codec::QuantPose pose; + }; + std::deque kept; + pose_batch::Sender upload; + + Packet header(PacketKind kind, std::uint64_t now) { + Packet p; + p.kind = kind; + p.session = secret; + p.map = map; + p.world = 1; + p.epoch = epoch; + p.source = id; + p.time_us = now; + p.sequence = ++sequence; + p.build = supported_build::game_sha256_bytes; + return p; + } + void hello(std::uint64_t now) { + wire().status.peers.push_back({id, true}); + auto p = header(PacketKind::hello, now); + p.text = name; + wire().inbound.push_back({id, encode_wire(p), now}); + } + void ready(std::uint64_t now) { + auto p = header(PacketKind::world_ready, now); + p.world_ready = true; + wire().inbound.push_back({id, encode_wire(p), now}); + } + 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); + auto p = header(PacketKind::pose, now); + p.pose.root.position = at; + p.pose.root.rotation = {0.f, std::sin(heading * 0.5f), 0.f, std::cos(heading * 0.5f)}; + p.pose.skater.resize(pose_codec::finger_skeleton); + p.pose.skater[1].position = at; + p.pose.board.resize(16); + p.pose.board[0].position = at; + kept.push_back({p.sequence, p.time_us, pose_codec::quantize(p.pose)}); + while (kept.size() > 64) kept.pop_front(); + const auto find = [&](std::uint32_t sequence) -> std::optional { + for (auto it = kept.rbegin(); it != kept.rend(); ++it) + if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose}; + return {}; + }; + const auto emit = [&](std::span message, bool) { + wire().inbound.push_back({id, {message.begin(), message.end()}, now}); + return true; + }; + upload.begin(1, map); + upload.add(p.source, p.epoch, *find(p.sequence), find, false, false, pose_batch::Rate::full, now, emit); + upload.flush(emit); + } +}; + +int run() { + const auto root = std::filesystem::temp_directory_path() / "reskate_server_plugin_tests"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "Mods"); + load_levels(root / "Mods"); // the retail maps + ServerConfig config; + config.file = root / "server" / "ReSkateServer.json"; + config.mods = root / "Mods"; + config.auto_update = false; + config.activity_log = false; + config.roles = default_roles(); + check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); + + // A plugin that prints the skaters it sees every 20th tick (a second here), and one without + // session.skaters. Ticks, not reskate.time: its clock is the real one, and this test's is not. + write(root / "Plugins" / "watcher" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.skaters"]})"); + write(root / "Plugins" / "watcher" / "plugin" / "main.lua", R"( + local ticks = 0 + reskate.on("tick", function() + ticks += 1 + if ticks % 20 ~= 0 then return end + local list = reskate.session.skaters() + reskate.console.print("skaters " .. #list) + for _, s in ipairs(list) do + reskate.console.print(string.format("skater %s at %.1f %.1f %.1f facing %.2f, %s", s.id, s.position.x, s.position.y, + s.position.z, s.heading, s.age_ms >= 0 and s.age_ms < 1000 and "fresh" or ("stale " .. s.age_ms))) + end + end) + )"); + write(root / "Plugins" / "blind" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console"]})"); + write(root / "Plugins" / "blind" / "plugin" / "main.lua", R"( + local ticks = 0 + reskate.on("tick", function() + ticks += 1 + if ticks % 20 ~= 0 then return end + local ok, why = pcall(reskate.session.skaters) + reskate.console.print(ok and "blind saw them" or "blind refused: " .. tostring(why)) + end) + )"); + + std::vector log; + SteamTransport transport; + Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); + std::string error; + if (!host.start(error)) { + std::fprintf(stderr, "The server did not start: %s\n", error.c_str()); + return 1; + } + const auto invite = parse_invite(host.invite()); + if (!invite) { + std::fprintf(stderr, "No invite.\n"); + return 1; + } + std::vector players(2); + for (std::size_t i = 0; i < players.size(); ++i) { + auto &p = players[i]; + p.id = 76561198000000001ULL + i; + p.epoch = 5000 + i; + p.secret = invite->secret; + p.map = map_hash(map_destination(config.map)); + p.name = "Player " + std::to_string(i + 1); + } + players[0].at = {10.f, 2.f, -30.f}; + players[0].heading = 1.f; + players[1].at = {-4.f, 7.5f, 12.f}; + + std::uint64_t now = 1000000000ULL, next_ready = now; + bool posing{}; + const auto run_for = [&](std::uint64_t microseconds) { + for (const auto end = now + microseconds; now < end; now += 50000) { + if (now >= next_ready) { + next_ready = now + 1000000; + for (auto &p : players) p.ready(now); + } + if (posing) + for (auto &p : players) p.pose(now); + host.tick(now); + } + }; + // Before anyone is in the world, the plugin sees nobody. + run_for(3000000); + check(has(log, "skaters 0"), "The plugin did not run, or saw skaters on an empty server"); + check(std::any_of(log.begin(), log.end(), + [](const auto &line) { return line.starts_with("blind refused: ") && line.find("session.skaters") != std::string::npos; }), + "A plugin without session.skaters was not refused"); + check(!has(log, "blind saw them"), "A plugin without session.skaters saw the skaters"); + + // Joined and in the world, but no pose yet: still nobody to see. + for (auto &p : players) p.hello(now); + run_for(3000000); + log.clear(); + run_for(1100000); + check(has(log, "skaters 0"), "A player with no pose yet was a skater"); + + // Posing: each where their latest pose put them, facing as it faces. + posing = true; + run_for(3000000); + log.clear(); + run_for(1100000); + check(has(log, "skaters 2"), "The plugin did not see both skaters"); + check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, fresh"), "The first skater's place, heading or age is wrong"); + check(has(log, "skater 76561198000000002 at -4.0 7.5 12.0 facing 0.00, fresh"), "The second skater's place, heading or age is wrong"); + // A skater who stopped sending stays where they were, older. + posing = false; + run_for(2000000); + log.clear(); + run_for(1100000); + check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses"); + if (failures) + for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str()); + return failures ? 1 : 0; +} +} // namespace + +int main() { + const int result = run(); + if (result == 0) std::printf("server plugin tests passed\n"); + return result; +} diff --git a/Server/server_host.cpp b/Server/server_host.cpp index fa501a93..6ba1a32e 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -215,6 +215,14 @@ std::optional> Host::player_position(std::uint64_t steam_id return std::nullopt; } +std::vector Host::skater_poses() const { + std::vector list; + for (const auto &[id, guest] : guests_) + if (guest && guest->handshaken && guest->world_ready && guest->latest_root && guest->pose_arrival && now_ >= guest->pose_arrival) + list.push_back({id, *guest->latest_root, now_ - guest->pose_arrival}); + return list; +} + // A player without the map is downloading it: the clock they are loading against starts over // each time they say so, for as long as map_fetch_limit_us from the first time. A slot is still // never held for good, and a player who stops saying it has the usual time to load. diff --git a/Server/server_host.h b/Server/server_host.h index ae9edd04..07d2d920 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -82,6 +82,14 @@ class Host { std::uint8_t player_locks(std::uint64_t id) const { return locks_of(id); } std::uint8_t set_locks(std::uint64_t id, std::uint8_t locks, const std::string &source); std::optional> player_position(std::uint64_t steam_id) const; + // Where each player in the world last said their skater was (their latest pose's root), and + // how long ago that arrived: what the server's plugins see of the skaters. + struct SkaterPose { + std::uint64_t id{}; + Transform root; + std::uint64_t age_us{}; + }; + std::vector skater_poses() const; void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); void change_map(std::string_view map); // a level name, level path or destination // A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0). diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 28f3c748..b2223bf9 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -79,6 +80,18 @@ class ServerGame final : public Game { host_.set_locks(player, locks, "plugin " + std::string(plugin)); return {}; } + // 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 peer_skaters() override { + std::vector result; + for (const auto &s : host_.skater_poses()) { + const auto &q = s.root.rotation; + // The rotation's +Z about world up, as a client's plugin_game_runtime.cpp reads it. + const float heading = std::atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1])); + result.push_back({s.id, s.root.position, heading, static_cast(s.age_us) / 1000.0}); + } + return result; + } // 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));