diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index a2f7ef5..be3aeda 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -11,6 +11,7 @@ #include namespace dingosdk { +constexpr unsigned max_map_rotation = 1440; // minutes a server's rotation keeps one map, at most struct MultiplayerLobby { std::uint64_t id{}, owner{}; std::string name, map, code; @@ -140,6 +141,9 @@ struct MultiplayerModel { unsigned server_ban_total{}; // For its admins: the levels (assets) the dedicated server can switch to. std::vector server_maps; + std::vector server_map_pool; // in rotation order; empty: every map + unsigned server_map_rotation{}; // minutes per map (0: off) + bool server_map_votes{}; // players may vote for a map MultiplayerHostPreferences saved_host; bool force_world_layers{}; std::uint64_t host_id{}; diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 559ab56..4a55395 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -356,7 +356,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 }))) throw std::invalid_argument("Invalid ban list"); if (p.kind == PacketKind::maps && - (!p.source || p.maps.size() > max_server_maps || + (!p.source || p.maps.size() > max_server_maps || !valid_map_pool(p.map_pool, p.maps.size()) || + p.map_rotation > max_map_rotation || std::any_of(p.maps.begin(), p.maps.end(), [](const auto &asset) { return !valid_map_asset(asset); }))) throw std::invalid_argument("Invalid server map list"); if (p.kind == PacketKind::hello && !p.text.empty() && !valid_member_name(p.text)) @@ -366,6 +367,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 if (p.kind == PacketKind::map_offer && (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))) throw std::invalid_argument("Invalid host map destination"); + if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_label(p.map_label)) + throw std::invalid_argument("Invalid map name"); if (p.kind == PacketKind::world_state && (p.destination.empty() ? (p.map != 0 || p.world_ready) : (!valid_map_destination(p.destination) || p.map != map_hash(p.destination)))) @@ -408,6 +411,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.map_authorized ? 1 : 0, 1); w.integer(p.destination.size(), 2); w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end()); + w.integer(p.map_label.size(), 1); + w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); } if (p.kind == PacketKind::hello) { w.integer(p.text.size(), 1); @@ -418,6 +423,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.world_ready ? 1 : 0, 1); w.integer(p.destination.size(), 2); w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end()); + w.integer(p.map_label.size(), 1); + w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); } else if (p.kind == PacketKind::world_ready) { w.integer(p.world_ready ? 1 : 0, 1); } else if (p.kind == PacketKind::pose) { @@ -515,6 +522,9 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(asset.size(), 1); w.bytes.insert(w.bytes.end(), asset.begin(), asset.end()); } + w.integer(p.map_pool.size(), 2); + for (const auto index : p.map_pool) w.integer(index, 2); + w.integer(p.map_rotation, 2); } if (p.kind == PacketKind::bans) { w.integer(p.ban_total, 4); @@ -592,6 +602,13 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.bytes[8 + i] = static_cast(actual_payload >> (8 * i)); return std::move(w.bytes); } +static bool read_map_label(Reader &r, std::span bytes, Packet &p) { + const auto length = r.integer(1); + if (length > max_member_name || length > bytes.size() - r.at) return false; + p.map_label.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); + r.at += static_cast(length); + return valid_map_label(p.map_label); +} std::optional decode(std::span bytes) noexcept { try { if (bytes.size() < header_size || bytes.size() > max_packet) @@ -630,7 +647,7 @@ std::optional decode(std::span bytes) noexcept { p.destination.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); - if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination)) + if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p)) return {}; } if (p.kind == PacketKind::hello) { @@ -655,6 +672,7 @@ std::optional decode(std::span bytes) noexcept { if (p.destination.empty() ? (p.map != 0 || p.world_ready) : (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))) return {}; + if (!read_map_label(r, bytes, p)) return {}; } } else if (p.kind == PacketKind::pose) { const auto skater = r.integer(2), board = r.integer(2); @@ -789,6 +807,11 @@ std::optional decode(std::span bytes) noexcept { if (!valid_map_asset(asset)) return {}; p.maps.push_back(std::move(asset)); } + const auto pooled = r.integer(2); + if (pooled > p.maps.size()) return {}; + for (std::uint64_t i = 0; i < pooled; ++i) p.map_pool.push_back(static_cast(r.integer(2))); + p.map_rotation = static_cast(r.integer(2)); + if (!valid_map_pool(p.map_pool, p.maps.size()) || p.map_rotation > max_map_rotation) return {}; } else if (p.kind == PacketKind::bans) { p.ban_total = static_cast(r.integer(4)); const auto count = r.integer(2); @@ -958,6 +981,15 @@ bool valid_map_asset(std::string_view asset) noexcept { return !asset.empty() && asset.size() <= max_map_asset && std::all_of(asset.begin(), asset.end(), [](char c) { return c > 32 && c < 127 && c != '|'; }); } +bool valid_map_label(std::string_view label) noexcept { + return label.empty() || (label.size() <= max_member_name && valid_member_name(label)); +} +bool valid_map_pool(std::span pool, std::size_t maps) noexcept { + if (pool.size() > maps) return false; + for (std::size_t i = 0; i < pool.size(); ++i) + if (pool[i] >= maps || std::find(pool.begin(), pool.begin() + i, pool[i]) != pool.begin() + i) return false; + return true; +} bool valid_admin_text(std::string_view text) noexcept { if (text.empty() || text.size() > max_admin_text) return false; for (std::size_t at = 0; at < text.size();) { diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index a86d70f..521f348 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 41; +constexpr std::uint16_t protocol_version = 42; constexpr std::size_t max_throwdown_message = 4096; // Packet::tuning: the host's SkatePhysicsTuning differences (Extension/Skater/physics_tuning.h). constexpr std::size_t max_physics_tuning = 16384; @@ -110,6 +110,8 @@ constexpr std::size_t max_ban_rows = 256; constexpr std::size_t max_server_maps = 128, max_map_asset = 128; // A level asset as a server's map list carries it: printable ASCII, no '|'. bool valid_map_asset(std::string_view asset) noexcept; +bool valid_map_pool(std::span pool, std::size_t maps) noexcept; // distinct indices below `maps` +bool valid_map_label(std::string_view label) noexcept; // empty, or a name like a member's struct Transform { std::array position{}; std::array rotation{0, 0, 0, 1}; @@ -187,6 +189,9 @@ struct Packet { std::vector bans; // bans: newest first std::uint32_t ban_total{}; // bans: how many the server has in all std::vector maps; // maps: level assets + std::vector map_pool; // maps: the pool as indices into `maps`, rotation order (empty: every map) + std::uint16_t map_rotation{}; // maps: minutes per map (0: off) + std::string map_label; // map_offer, world_state: the map's name for people (may be empty) std::vector throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes) std::array teleport{}; // teleport: where the receiver goes (world position) std::vector tuning; // physics_tuning: 0..max_physics_tuning bytes diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 2500dff..8a1c712 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -3,6 +3,7 @@ #include "Extension/Multiplayer/Hud/native_indicators.h" #include "Extension/Multiplayer/Hud/native_party.h" #include "party_book.h" +#include "Engine/Game/World/world_names.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" #include "Extension/Multiplayer/Hud/follow_camera.h" @@ -75,6 +76,8 @@ void stop(Session &s, std::string reason) { s.server_bans.clear(); s.server_ban_total = 0; s.server_maps.clear(); + s.server_map_pool.clear(); + s.server_map_rotation = 0; set_lobby_object_placement_allowed(true); s.guest_noclip = s.guest_no_bail = s.guest_boosts = true; s.enforce_tuning = true; @@ -127,6 +130,7 @@ void stop(Session &s, std::string reason) { s.awaiting_map = s.join_map_authorized = s.map_load_submitted = false; s.join_started = s.last_map_request = s.last_map_load_check = 0; s.join_destination.clear(); + s.map_label.clear(); s.world = 1; s.travelling = false; s.host_world_ready = true; @@ -545,6 +549,7 @@ MultiplayerModel model() { std::lock_guard lock(s.mutex); return s.view; } +std::string take_leave_notice() { return std::exchange(session().leave_notice, {}); } MultiplayerChat chat() { auto &s = session(); std::lock_guard lock(s.mutex); @@ -637,6 +642,14 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade s.last_map_load_check = now; std::string detail; const auto result = loader(s.join_destination, s.map_load_submitted, detail); + if (result == MapLoadResult::missing) { + const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; + const char *who = dedicated_host(s) ? "server" : "host"; + s.leave_notice = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") + + name + ", which is not installed on this PC. Install its map mod and join again."; + stop(s, s.leave_notice); + return false; + } if (result == MapLoadResult::failed) { stop(s, detail.empty() ? "The host's map could not be loaded." : std::move(detail)); return false; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index 06ec342..0b7bc7b 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -10,12 +10,13 @@ std::string command(std::string_view action, std::string_view argument = {}, std bool queue_command(std::string_view action, std::string_view argument, std::string_view password); // Called just before the host's validated native load is submitted, on the client thread. void host_map_change(std::string_view destination); -enum class MapLoadResult { waiting, queued, failed }; +enum class MapLoadResult { waiting, queued, failed, missing }; // missing: the map is not installed // Runs on the client thread; only catalog-registered destinations may be queued. using MapLoader = MapLoadResult (*)(std::string_view map, bool submitted, std::string &detail); void tick(std::uintptr_t base, std::uintptr_t client, bool gameplay_ready, std::string_view map, MapLoader loader = nullptr); MultiplayerModel model(); +std::string take_leave_notice(); // why the session just ended over a missing map, once; client thread // The session's chat lines for the overlay. Thread-safe and cheap: callable every frame. MultiplayerChat chat(); } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index b46a57e..3c2c4a3 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -208,6 +208,10 @@ struct Session { std::vector server_bans; std::uint32_t server_ban_total{}; std::vector server_maps; // level assets the dedicated server allows + std::vector server_map_pool; // admins: the pool's assets in rotation order (empty: every map) + unsigned server_map_rotation{}; // minutes per map (0: off) + std::string map_label; // the host's name for join_destination (may be empty) + std::string leave_notice; // take_leave_notice() std::uint64_t joined_public_lobby{}; std::array peers; // Players take the lowest free slots, so every one sits below this mark diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 9492558..b2b28d5 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -458,6 +458,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.travel_started = s.join_started = now; s.join_destination.clear(); s.map_name.clear(); + s.map_label.clear(); s.map = 0; s.map_load_submitted = false; s.last_map_load_check = 0; @@ -469,6 +470,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.world_state_sequence = p.sequence; if (!p.destination.empty()) { s.join_destination = s.map_name = p.destination; + s.map_label = p.map_label; s.map = p.map; } s.host_world_ready = p.world_ready; @@ -567,6 +569,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { link->password_challenge = p.challenge; link->map_authorized |= p.map_authorized; s.join_destination = p.destination; + s.map_label = p.map_label; s.world = p.world; s.join_map_authorized |= p.map_authorized; if (new_challenge) @@ -749,6 +752,9 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { if (p.kind == PacketKind::maps) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { s.server_maps = p.maps; + s.server_map_pool.clear(); + for (const auto entry : p.map_pool) s.server_map_pool.push_back(p.maps[entry]); + s.server_map_rotation = p.map_rotation; publish(s); publish_chat(s); } diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 1ab9925..d6d2e41 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -214,6 +214,9 @@ void publish(Session &s, const NativeFrame *local) { view.server_bans = true; view.server_ban_total = s.server_ban_total; view.server_maps = s.server_maps; + view.server_map_pool = s.server_map_pool; + view.server_map_rotation = s.server_map_rotation; + view.server_map_votes = (s.server_votes & server_vote_map) != 0; } view.chat_visible = s.chat_visible; view.chat_filter = s.chat_filter; @@ -447,6 +450,8 @@ std::vector chat_commands(const Session &s) { list.push_back({"/tod", "/tod