diff --git a/CMakeLists.txt b/CMakeLists.txt index 34277fc..639d84f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -142,8 +142,8 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_executable(dingosdk_multiplayer_tests Extension/Multiplayer/Test/protocol_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp Server/steam_server.cpp - Extension/Throwdowns/throwdown_wire.cpp) - target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter) + Extension/Throwdowns/throwdown_wire.cpp Extension/Multiplayer/developer_identity.cpp) + target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter dingosdk_json) add_test(NAME multiplayer_protocol COMMAND dingosdk_multiplayer_tests) add_executable(dingosdk_throwdown_relay_tests Extension/Throwdowns/Test/throwdown_relay_tests.cpp Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp) diff --git a/Engine/Game/Multiplayer/object_placement.h b/Engine/Game/Multiplayer/object_placement.h index e53010a..443b9b1 100644 --- a/Engine/Game/Multiplayer/object_placement.h +++ b/Engine/Game/Multiplayer/object_placement.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -27,4 +28,21 @@ inline std::optional parse_object_placement(std::string_view ar : current == ObjectPlacement::host_only ? ObjectPlacement::nobody : ObjectPlacement::everyone; return {}; } +// How many objects each player may have placed in a session, set by the host or the server: +// 0 is no limit beyond the protocol's own (max_object_limit). The host and a server's admins +// are not held to it. A player over it keeps what everyone already sees of theirs; only the +// objects past the limit are left out. +inline constexpr unsigned max_object_limit = 1024; +// What a server or a host starts with, until they choose otherwise. +inline constexpr unsigned default_object_limit = 100; +inline constexpr bool valid_object_limit(std::uint64_t value) noexcept { return value <= max_object_limit; } +// Accepts a number from 1 to max_object_limit, and off|none|0 for no limit. +inline std::optional parse_object_limit(std::string_view argument) noexcept { + if (argument == "off" || argument == "none") return 0U; + unsigned value{}; + const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value); + if (argument.empty() || parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !valid_object_limit(value)) + return {}; + return value; +} } // namespace dingosdk diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 1e24eae..9ef549f 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -130,6 +130,9 @@ struct MultiplayerModel { std::vector bans; unsigned tps = multiplayer_default_tps; ObjectPlacement object_placement = ObjectPlacement::everyone; + // Objects each player may have placed in this session (0: no limit), the limit this + // player is held to (0 for the host and a server's admins), and how many they have placed. + unsigned object_limit{}, object_limit_own{}, objects_placed{}; // Host setting: whether guests may use noclip / No Bail (the host and server admins always may). bool guest_noclip{true}, guest_no_bail{true}, guest_boosts{true}; // Host setting: guests skate with the host's physics tuning (on a dedicated server: the diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 4a55395..26c7b57 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -558,6 +558,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 if (!valid_multiplayer_tps(p.tps)) throw std::invalid_argument("Invalid session TPS"); w.integer(p.tps, 1); w.integer(static_cast(p.object_placement), 1); + if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit"); + w.integer(p.object_limit, 2); w.integer(p.force_world_layers, 1); // One mode per world-layer catalog row; both peers read the same catalog // from the same game build. @@ -864,6 +866,9 @@ std::optional decode(std::span bytes) noexcept { const auto placement = r.integer(1); if (!valid_object_placement(placement)) return {}; p.object_placement = static_cast(placement); + const auto object_limit = r.integer(2); + if (!valid_object_limit(object_limit)) return {}; + p.object_limit = static_cast(object_limit); const auto forced = r.integer(1); if (forced > 1) return {}; p.force_world_layers = forced != 0; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index d10d010..25c3540 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 = 42; +constexpr std::uint16_t protocol_version = 43; 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; @@ -171,6 +171,8 @@ struct Packet { // lock their editor and native tools from it; the host enforces it by // freezing guest layouts (see publish_guest_objects). ObjectPlacement object_placement = ObjectPlacement::everyone; + // Objects each player may have placed (object_placement.h); 0: no limit. + unsigned object_limit{}; // Bumped each time the host deletes all guest objects. Guests delete their // own session objects when it changes after their first roster. std::uint32_t object_clears{}; diff --git a/Extension/Multiplayer/Session/object_state.h b/Extension/Multiplayer/Session/object_state.h index 20554da..822197b 100644 --- a/Extension/Multiplayer/Session/object_state.h +++ b/Extension/Multiplayer/Session/object_state.h @@ -49,6 +49,16 @@ inline bool valid_object_chunk(const ObjectChunk &chunk) { return false; return true; } +// What of an owner's uploaded `layout` everyone else is shown under a limit of `limit` objects +// (0: all of it). The objects already shown (`shared`) keep their place, so placing one too +// many never takes away one that is there; the rest fill what room is left, oldest ID first. +inline std::vector limited_layout(std::vector layout, const std::map &shared, + std::size_t limit) { + if (!limit || layout.size() <= limit) return layout; + std::stable_partition(layout.begin(), layout.end(), [&](const NetworkObject &object) { return shared.contains(object.id); }); + layout.resize(limit); + return layout; +} // Per-owner state. A multipart replacement/delta becomes visible only after all // parts validate. Reliable delivery keeps parts ordered; stale revisions cannot // resurrect deleted objects. No wire IDs are native entity handles. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 606d065..697b210 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -73,6 +73,8 @@ void stop(Session &s, std::string reason) { s.voice_policy = {}; s.tps = multiplayer_default_tps; s.object_placement = ObjectPlacement::everyone; + s.object_limit = 0; + set_lobby_object_limit(0); s.server_admin = false; s.server_bans.clear(); s.server_ban_total = 0; diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 018ba9a..5177954 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -27,6 +27,10 @@ void apply_distances(Session &s, const MultiplayerDistances &distances) { // previous band's hysteresis or waiting for its old send deadline. for (auto &peer : active_peers(s)) peer.pose_delivery = {}; } +void apply_object_limit(Session &s, unsigned limit) { + s.object_limit = limit; + set_lobby_object_limit(s.mode == Mode::host || s.server_admin ? 0 : limit); +} void apply_object_placement(Session &s, ObjectPlacement policy) { s.object_placement = policy; // On a dedicated server "host only" means its admins. @@ -288,6 +292,21 @@ std::string edit_object_placement(Session &s, std::string_view argument) { : *policy == ObjectPlacement::host_only ? "Only you can place objects. Guests' objects are frozen." : "Object placement is disabled for everyone. Existing objects stay."; } +std::string edit_object_limit(Session &s, std::string_view argument) { + if (s.mode != Mode::host) return "Only the session host can change the object limit."; + const auto limit = parse_object_limit(argument); + if (!limit) return "Use a number of objects from 1 to " + std::to_string(max_object_limit) + ", or off."; + // Guests' games stop them at the limit when the roster arrives; enforcement is the host + // showing everyone no more than that of each guest's layout (publish_guest_objects). + apply_object_limit(s, *limit); + for (auto &peer : active_peers(s)) peer.shared_from = 0; // look at every layout again + s.roster_dirty = true; + load_host_preferences(s); + s.host_preferences.object_limit = *limit; + save_host_preferences(s); + return *limit ? "Each guest can place up to " + std::to_string(*limit) + " objects." + : std::string("Guests can place as many objects as they like."); +} // Removes every guest's objects for everyone, whatever the placement policy. // The host's own objects are its saved park and are left alone. std::string clear_guest_objects(Session &s, std::string_view) { @@ -541,7 +560,7 @@ bool own_mark_command(std::string_view action) { bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { if (launcher::offline_mode() && !own_mark_command(action)) return false; if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && - action != "distances" && action != "object-placement" && action != "kick" && action != "clear-objects" && + action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" && action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" && action != "chat-bubbles-duration" && action != "chat-bubbles-history" && @@ -581,7 +600,7 @@ std::string command(std::string_view action, std::string_view argument, std::str // their own: the same menu actions, sent to the server. "server" sends // any server console command. if (dedicated_host(s) && (action == "server" || (s.server_admin && - (action == "distances" || action == "object-placement" || action == "voice-allow" || action == "voice-range" || + (action == "distances" || action == "object-placement" || action == "object-limit" || action == "voice-allow" || action == "voice-range" || action == "clear-objects" || action == "kick" || action == "ban" || action == "unban" || action == "world-layer-sync" || action == "noclip-allow" || action == "nobail-allow" || action == "tpall" || action == "tphere" || action == "boosts-allow" || action == "tuning-enforce")))) { @@ -716,7 +735,7 @@ std::string command(std::string_view action, std::string_view argument, std::str // Host-only settings check the mode themselves; guests get a refusal. using Setting = std::string (*)(Session &, std::string_view); static constexpr std::pair settings[] = { - {"object-placement", edit_object_placement}, {"kick", kick_player}, + {"object-placement", edit_object_placement}, {"object-limit", edit_object_limit}, {"kick", kick_player}, {"noclip-allow", edit_guest_noclip}, {"nobail-allow", edit_guest_no_bail}, {"boosts-allow", edit_guest_boosts}, {"tuning-enforce", edit_enforce_tuning}, {"score-check", edit_score_check}, @@ -919,6 +938,7 @@ std::string command(std::string_view action, std::string_view argument, std::str apply_distances(s, remembered.distances); s.voice_range = remembered.voice_range; apply_object_placement(s, remembered.placement); + apply_object_limit(s, remembered.object_limit); apply_guest_tools(s, remembered.guest_noclip, remembered.guest_no_bail, remembered.guest_boosts); s.enforce_tuning = remembered.enforce_tuning; s.score_check = remembered.score_check; diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 1d40d7a..3229f37 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -146,6 +146,7 @@ struct Session { std::uint64_t next_publish{}, last_client_log{}, next_party_update{}; MultiplayerDistances distances; ObjectPlacement object_placement = ObjectPlacement::everyone; + unsigned object_limit{}; // objects each player may have placed; 0: no limit // What guests may use: the host's choice, or the host's roster for a guest. bool guest_noclip = true, guest_no_bail = true, guest_boosts = true; // Host: guests skate with its physics tuning. Guest: the host's roster says so (a @@ -242,6 +243,7 @@ struct Session { std::string lobby_name; MultiplayerDistances distances; ObjectPlacement placement = ObjectPlacement::everyone; + unsigned object_limit = default_object_limit; float voice_range = default_voice_range; bool guest_noclip = true, guest_no_bail = true, guest_boosts = true; bool enforce_tuning = true; @@ -433,6 +435,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now); // session_commands.cpp void apply_distances(Session &s, const MultiplayerDistances &distances); void apply_object_placement(Session &s, ObjectPlacement policy); +// The session's limit, and this game's own share of it: none while hosting or as a server's admin. +void apply_object_limit(Session &s, unsigned limit); // Stores what guests may use and applies it to the local player (the host and a dedicated // server's admins are exempt). void apply_guest_tools(Session &s, bool noclip, bool no_bail, bool boosts); diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index b2b28d5..b1cda91 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -202,6 +202,7 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) { for (auto &peer : active_peers(s)) peer.pose_delivery = {}; } apply_object_placement(s, p.object_placement); + apply_object_limit(s, p.object_limit); // after server_admin, which exempts an admin apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts); s.enforce_tuning = p.enforce_tuning; s.server_votes = dedicated_host(s) ? p.server_votes : 0; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 5f8be1d..4e88dc4 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -26,6 +26,8 @@ void publish_guest_objects(Session &s) { if (peer.handshaken && peer.objects.revision() != peer.shared_from) { auto layout = peer.objects.layout(); std::erase_if(layout, [&](const auto &object) { return peer.cleared.contains(object.id); }); + // The host's limit on each guest's objects, which a patched client cannot place past. + layout = limited_layout(std::move(layout), peer.shared.objects(), s.object_limit); peer.shared.replace(layout); peer.shared_from = peer.objects.revision(); } @@ -429,6 +431,7 @@ void send_roster(Session &s, std::uint64_t now) { p.voice_range = s.voice_range; p.distances = s.distances; p.object_placement = s.object_placement; + p.object_limit = s.object_limit; p.guest_noclip = s.guest_noclip; p.guest_no_bail = s.guest_no_bail; p.guest_boosts = s.guest_boosts; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index cd6bf37..7644e76 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -50,6 +50,8 @@ void load_host_preferences(Session &s) { p.voice_range = value->get(); if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast(*value))) p.placement = static_cast(*value); + if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast(*value))) + p.object_limit = static_cast(*value); p.guest_noclip = profile_runtime::local_preference("Host.GuestNoclip").value_or(true); p.guest_no_bail = profile_runtime::local_preference("Host.GuestNoBail").value_or(true); p.guest_boosts = profile_runtime::local_preference("Host.GuestBoosts").value_or(true); @@ -70,6 +72,7 @@ void save_host_preferences(const Session &s) { {"Host.Distance.HalfReturn", static_cast(p.distances.half_rate_return)}, {"Host.Distance.LowStart", static_cast(p.distances.low_rate_start)}, {"Host.ObjectPlacement", static_cast(p.placement)}, + {"Host.ObjectLimit", static_cast(p.object_limit)}, {"Host.GuestNoclip", p.guest_noclip}, {"Host.GuestNoBail", p.guest_no_bail}, {"Host.GuestBoosts", p.guest_boosts}, @@ -147,6 +150,9 @@ void publish(Session &s, const NativeFrame *local) { view.tps = s.tps; view.distances = s.distances; view.object_placement = s.object_placement; + view.object_limit = s.object_limit; + view.object_limit_own = s.mode == Mode::host || s.server_admin ? 0 : s.object_limit; // as apply_object_limit gives this game + view.objects_placed = s.mode == Mode::off ? 0 : static_cast(s.local_objects.objects().size()); view.guest_noclip = s.guest_noclip; view.guest_no_bail = s.guest_no_bail; view.guest_boosts = s.guest_boosts; diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index c6bf74c..20e7a40 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -29,6 +29,9 @@ void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch) { std::string network_object_status() { return {}; } bool simulated_placement_allowed = true; void set_lobby_object_placement_allowed(bool allowed) { simulated_placement_allowed = allowed; } +unsigned simulated_object_limit{}; +void set_lobby_object_limit(unsigned limit) noexcept { simulated_object_limit = limit; } +unsigned lobby_object_limit() noexcept { return simulated_object_limit; } unsigned simulated_guest_wipes{}; bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; } } diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 029e0dc..8aed8e1 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -721,6 +721,7 @@ void dedicated_server_codec() { roster.members = {{server, 10, "My server"}, {player, 20, "Skater", true}, {other, 30, "Other"}}; roster.voice_range = 450; roster.guest_noclip = false; + roster.object_limit = 50; Packet teleport; teleport.kind = PacketKind::teleport; teleport.session = 9; teleport.epoch = 10; teleport.map = 11; teleport.source = server; teleport.teleport = {612.5f, 199.25f, -1075.75f}; @@ -744,6 +745,47 @@ void dedicated_server_codec() { "Server roster, admin flags or voice range lost"); check(decoded && !decoded->guest_noclip && decoded->guest_no_bail && decoded->guest_boosts, "Guest noclip / No Bail / boost permissions lost"); + // The limit on each player's objects rides in the roster too; one past the protocol's own is refused. + check(decoded && decoded->object_limit == 50, "The object limit was lost"); + { + auto unlimited = roster; + unlimited.object_limit = 0; + const auto back = decode(encode(unlimited)); + check(back && back->object_limit == 0, "No object limit did not stay none"); + auto over = roster; + over.object_limit = dingosdk::max_object_limit + 1; + bool refused{}; + try { + encode(over); + } catch (const std::exception &) { + refused = true; + } + check(refused, "An object limit past the protocol's was sent"); + check(dingosdk::parse_object_limit("25") == 25U && dingosdk::parse_object_limit("off") == 0U && + dingosdk::parse_object_limit("1024") == 1024U && !dingosdk::parse_object_limit("1025") && + !dingosdk::parse_object_limit("") && !dingosdk::parse_object_limit("-1") && !dingosdk::parse_object_limit("ten"), + "Object limits were not read"); + // What a host shows of a layout under a limit: what is already shown stays, the rest fills up in order. + const auto object = [](std::uint64_t id) { + dingosdk::NetworkObject value; + value.id = id; + value.item = "own_bk_test"; + return value; + }; + const auto ids = [](const std::vector &layout) { + std::vector out; + for (const auto &entry : layout) out.push_back(entry.id); + return out; + }; + const std::vector uploaded{object(1), object(2), object(3), object(4), object(5)}; + std::map shown{{4, object(4)}, {5, object(5)}}; + check(ids(limited_layout(uploaded, {}, 0)) == std::vector{1, 2, 3, 4, 5} && + ids(limited_layout(uploaded, {}, 9)) == std::vector{1, 2, 3, 4, 5}, + "A layout within the limit was cut"); + check(ids(limited_layout(uploaded, {}, 3)) == std::vector{1, 2, 3}, "A layout over the limit was not cut to it"); + check(ids(limited_layout(uploaded, shown, 3)) == std::vector{4, 5, 1}, + "Objects already shown lost their place to newer ones"); + } // Parties ride in the roster: each has one leader and at least two members; a server is in none. auto party = roster; party.members[1].party = party.members[2].party = 7; diff --git a/Extension/Objects/ParkEditor/park_editor_lobby.cpp b/Extension/Objects/ParkEditor/park_editor_lobby.cpp index f008733..13d6e1d 100644 --- a/Extension/Objects/ParkEditor/park_editor_lobby.cpp +++ b/Extension/Objects/ParkEditor/park_editor_lobby.cpp @@ -5,10 +5,26 @@ #include namespace dingosdk { -namespace { std::atomic placement_allowed{true}; } +namespace { +std::atomic placement_allowed{true}; +std::atomic object_limit{}; +} bool lobby_object_placement_allowed() noexcept { return placement_allowed.load(std::memory_order_acquire); } +void set_lobby_object_limit(unsigned limit) noexcept { object_limit.store(limit, std::memory_order_release); } +unsigned lobby_object_limit() noexcept { return object_limit.load(std::memory_order_acquire); } using namespace profile_runtime; using namespace profile_runtime::park_editor_detail; +// Not for callers that hold the native mutex: they have the layout, and count it themselves. +std::size_t lobby_object_count() { + std::lock_guard lock(local_runtime().native_mutex); + auto &r = placements_runtime(); + const auto found = r.document.maps.find(r.map); + return found == r.document.maps.end() ? 0 : found->second.size(); +} +bool lobby_object_limit_reached() { + const auto limit = lobby_object_limit(); + return limit && lobby_object_count() >= limit; +} void set_lobby_object_guest(bool guest) { std::lock_guard lock(local_runtime().native_mutex); auto &r = placements_runtime(); diff --git a/Extension/Objects/ParkEditor/park_editor_operations.cpp b/Extension/Objects/ParkEditor/park_editor_operations.cpp index 6169147..78fec0b 100644 --- a/Extension/Objects/ParkEditor/park_editor_operations.cpp +++ b/Extension/Objects/ParkEditor/park_editor_operations.cpp @@ -105,6 +105,9 @@ std::string edit_local_park(std::string_view operation, std::string_view map, st auto object = supplied; if (r.next_id == UINT64_MAX || target.size() >= 1024) throw std::runtime_error("Park object limit reached."); + // The session's own limit on each player (the mutex is held: count here). + if (const auto limit = lobby_object_limit(); limit && target.size() >= limit) + throw std::runtime_error(object_limit_notice); object.id = r.next_id++; if (!e.assets || std::none_of(e.assets->begin(), e.assets->end(), [&](const auto &item) { return item.key == object.item; })) diff --git a/Extension/Objects/ParkEditor/park_editor_preview.cpp b/Extension/Objects/ParkEditor/park_editor_preview.cpp index e1bdfbc..706c7a9 100644 --- a/Extension/Objects/ParkEditor/park_editor_preview.cpp +++ b/Extension/Objects/ParkEditor/park_editor_preview.cpp @@ -66,6 +66,11 @@ bool queue_local_park_paste(const EditorPasteRequest &request) { auto target = r.document.maps[r.map]; if (target.size() + request.objects.size() > 1024 || r.next_id > UINT64_MAX - request.objects.size()) return false; + // The session's own limit on each player: copies that would pass it are not made. + if (const auto limit = lobby_object_limit(); limit && target.size() + request.objects.size() > limit) { + e.status = object_limit_notice; + return false; + } auto next_id = r.next_id; for (const auto © : request.objects) { if (std::none_of(e.assets->begin(), e.assets->end(), @@ -103,6 +108,10 @@ bool queue_local_park_preview(const EditorPreviewRequest &request) { profile::PlacedObject object; std::uint64_t entity{}; if (placing) { + if (const auto limit = lobby_object_limit(); limit && layout.size() >= limit) { + e.status = object_limit_notice; + return false; + } if (layout.size() >= 1024 || r.next_id == UINT64_MAX || !e.assets || std::none_of(e.assets->begin(), e.assets->end(), [&](const auto &asset) { return asset.key == request.item; })) diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index a10faea..4872918 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -12,6 +12,14 @@ void set_park_mod_list_visible(bool visible) noexcept; // on disconnect; existing placed objects remain in the world. void set_lobby_object_placement_allowed(bool enabled); bool lobby_object_placement_allowed() noexcept; +// The session's limit on this player's objects (object_placement.h), 0 for none: the host's +// own game and a server admin's are given none. Adding an object is refused at the limit. +void set_lobby_object_limit(unsigned limit) noexcept; +unsigned lobby_object_limit() noexcept; +// Objects this player has on the current map, and whether one more would pass the limit. +std::size_t lobby_object_count(); +bool lobby_object_limit_reached(); +inline constexpr char object_limit_notice[] = "You have placed as many objects as this session allows each player."; bool queue_local_park_surface(const EditorSurfaceRequest &); bool queue_local_park_preview(const EditorPreviewRequest &); bool queue_local_park_selection(const EditorSelectionRequest &); diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 2f8f14a..9d727bc 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -311,6 +311,14 @@ void placement_message_hook(std::uintptr_t manager, const void* message) { PlacementBatch batch; batch.native = true; { PreserveError preserve; read(reinterpret_cast(message) + 0xc, batch.type); } + if (placement_adds_objects(batch.type) && lobby_object_placement_allowed() && lobby_object_limit_reached()) { + // As below: the request never reaches the server, so nothing is created. + PreserveError preserve; + placements_runtime().status = object_limit_notice; + dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{ + {"event", "local_placement_over_limit"}, {"type", batch.type}, {"limit", lobby_object_limit()}}.dump().c_str()); + return; + } if (placement_adds_objects(batch.type) && !lobby_object_placement_allowed()) { // Drop the request before the server sees it: no entity is created. PreserveError preserve; diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 773c314..25db9a2 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -168,7 +168,7 @@ void process_actions(const Context& context, const MultiplayerModel& model) { SecureZeroMemory(password.data(), password.size()); s.feedback = "Password is too long. Use a shorter password."; continue; } - if ((cmd == "kick" || cmd == "object-placement" || cmd == "clear-objects" || cmd == "world-layer-sync" || + if ((cmd == "kick" || cmd == "object-placement" || cmd == "object-limit" || cmd == "clear-objects" || cmd == "world-layer-sync" || cmd == "voice-allow" || cmd == "noclip-allow" || cmd == "nobail-allow" || cmd == "boosts-allow" || cmd == "tuning-enforce") && !model.hosting) { @@ -382,6 +382,15 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti ? std::string("Admins only") : std::string(object_placement_name(model.object_placement)); add_button(context, side, "editor", "Object placement: " + placement, model.hosting ? "object-placement" : "", "next", false, 136.f); + { + // The same round numbers as the overlay's list, one press each. + static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; + const auto found = std::find(limits.begin(), limits.end(), model.object_limit); + const auto next = limits[found == limits.end() ? 0 : static_cast(found - limits.begin() + 1) % limits.size()]; + add_button(context, side, "object-limit", + "Objects per player: " + (model.object_limit ? std::to_string(model.object_limit) : std::string("No limit")), + model.hosting ? "object-limit" : "", next ? std::to_string(next) : std::string("off"), false, 136.f); + } if (model.hosting) add_button(context, side, "clear-objects", "Delete all guest objects", "clear-objects", {}, false, 136.f); add_button(context, side, "guest-noclip", std::string("Guest noclip: ") + (model.guest_noclip ? "Allowed" : "Off"), diff --git a/Extension/UI/Overlay/multiplayer_session.cpp b/Extension/UI/Overlay/multiplayer_session.cpp index 14e06b6..62c18bc 100644 --- a/Extension/UI/Overlay/multiplayer_session.cpp +++ b/Extension/UI/Overlay/multiplayer_session.cpp @@ -608,6 +608,28 @@ void session_page(SkateMenu &menu, const Model &model) { : "Only the host can build. Your editor and object tools are locked.") : mp.dedicated ? "Nobody can build. Existing objects stay." : "Nobody can build, including the host. Existing objects stay."); + // How many objects each player may have placed: a few round numbers, and whatever the + // server's file or console set that is not one of them. + { + static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; + const auto found = std::find(limits.begin(), limits.end(), mp.object_limit); + const std::string custom = std::to_string(mp.object_limit); + std::array labels{"No limit", "10", "25", "50", "100", "250", "500", custom.c_str()}; + int picked = static_cast(found - limits.begin()); + field(menu, "Objects per player"); + ImGui::BeginDisabled(!controls_session(mp)); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::Combo("##object-limit", &picked, labels.data(), found == limits.end() ? 8 : 7) && picked < 7) + send_private(menu, "object-limit", picked ? std::to_string(limits[static_cast(picked)]) : std::string("off"), unused, false); + ImGui::EndDisabled(); + const char *exempt = mp.dedicated ? "Admins" : "The host"; + if (!mp.object_limit) note("Everyone can place as many objects as they like."); + else if (mp.object_limit_own) + note(("You have placed " + std::to_string(mp.objects_placed) + " of " + std::to_string(mp.object_limit_own) + + " objects. Delete one to place another once you reach the limit.").c_str()); + else note((std::string("Each player can have ") + std::to_string(mp.object_limit) + " objects placed. " + exempt + + (mp.dedicated ? " are not limited." : " is not limited.")).c_str()); + } if (controls_session(mp)) { note(mp.dedicated ? "Enforced by the server, so modified clients cannot get around it." : "Enforced by the host, so modified clients cannot get around it."); diff --git a/Server/README.txt b/Server/README.txt index b3faa69..4c6d3b5 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -119,6 +119,9 @@ voice_chat Allow voice chat. voice_range How far proximity voice reaches, 50-1000 m. distances When far-away players update less often (metres). object_placement everyone, admins (only admins can build), or nobody. +object_limit How many objects each player may have placed, 1-1024 + (default 100), or 0 for no limit. Admins are not limited. + A player at the limit deletes one to place another. noclip, no_bail, Let players use noclip (and tp) / No Bail / the forward and up boosts boosts (default true; admins always can). enforce_tuning Players skate with the game's own Gameplay/SkatePhysicsTuning, @@ -163,6 +166,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. tps 20|30|60|120 voice on|off voice-range distances placement everyone|admins|nobody clear-objects + objects |off How many objects each player may have placed. noclip on|off nobail on|off boosts on|off What players may use (admins always can). tuning on|off Everyone on the game's own physics tuning. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index d887d29..96072be 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -60,6 +60,15 @@ int run() { check(token_refused(tokened), "A steam_token with other characters was accepted"); tokened.steam_token = std::string(65, 'A'); check(token_refused(tokened), "An overlong steam_token was accepted"); + // object_limit: 100 unless set (0 is no limit), and kept on a rewrite. + check(has("object_limit") && config.object_limit == 100, "object_limit is not a new setting of 100"); + auto limited = reloaded; + limited.object_limit = 50; + save_config(limited); + check(load_config(file).object_limit == 50, "object_limit was not kept"); + limited.object_limit = 0; + save_config(limited); + check(load_config(file).object_limit == 0, "No object limit became the default again"); save_config(reloaded); // Server names: letters, digits, spaces and - _ / [ ] ( ) only. diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 4c370e4..518f3cb 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -50,6 +50,7 @@ Json to_json(const ServerConfig &c) { distances["low_rate_start"] = c.distances.low_rate_start; root["distances"] = std::move(distances); root["object_placement"] = placement_text(c.object_placement); + root["object_limit"] = c.object_limit; root["noclip"] = c.noclip; root["no_bail"] = c.no_bail; root["boosts"] = c.boosts; @@ -179,6 +180,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector | maps | name | password | welcome | listed on|off\n" "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances \n" - "placement everyone|admins|nobody | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" + "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | layer-sync on|off | layer default|on|off | tod \n" @@ -261,6 +261,7 @@ void Host::send_roster() { p.voice_range = config_.voice_range; p.distances = config_.distances; p.object_placement = config_.object_placement; + p.object_limit = config_.object_limit; p.guest_noclip = config_.noclip; p.guest_no_bail = config_.no_bail; p.guest_boosts = config_.boosts; @@ -698,6 +699,8 @@ void Host::sync_objects() { (config_.object_placement == ObjectPlacement::host_only && is_admin(id)))) { auto layout = guest->objects.layout(); std::erase_if(layout, [&](const auto &object) { return guest->cleared.contains(object.id); }); + // No more of a player's objects than the server allows each of them; admins are not limited. + if (!is_admin(id)) layout = limited_layout(std::move(layout), guest->shared.objects(), config_.object_limit); if (config_.activity_log) activity_.objects(id, guest->shared.layout(), layout); guest->shared.replace(layout); guest->shared_from = guest->objects.revision(); @@ -840,6 +843,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { const auto verb = lower(action); // The in-game menu's names for the same settings. const std::string name = verb == "voice-allow" ? "voice" : verb == "object-placement" ? "placement" + : verb == "object-limit" ? "objects" : verb == "world-layer-sync" ? "layer-sync" : verb == "noclip-allow" ? "noclip" : verb == "nobail-allow" ? "nobail" : verb == "boosts-allow" ? "boosts" : verb == "tuning-enforce" ? "tuning" : verb; @@ -1190,6 +1194,14 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { : *policy == ObjectPlacement::host_only ? "Only admins can place objects. Everyone else's are frozen." : "Object placement is off. Existing objects stay."); } + if (name == "objects") { + const auto limit = parse_object_limit(argument); + if (!limit) return "objects <1-" + std::to_string(max_object_limit) + ">|off"; + config_.object_limit = *limit; + for (auto &[id, guest] : guests_) guest->shared_from = 0; // look at every layout again + return changed(*limit ? "Each player can place up to " + std::to_string(*limit) + " objects. Admins are not limited." + : std::string("Players can place as many objects as they like.")); + } if (name == "votes") { // votes | votes on|off| | votes seconds|cooldown const auto [what_text, value_text] = split(argument);