From 13a64c2e913b5f6b4325cfa2eb1d29fc8a5ca7f4 Mon Sep 17 00:00:00 2001 From: ComputerKWasTaken Date: Sun, 4 Oct 2026 16:40:57 -0500 Subject: [PATCH 1/2] Add random community park loading and launch randomization --- CMakeLists.txt | 5 ++ .../World/Test/park_randomization_tests.cpp | 52 +++++++++++++++++++ Engine/Game/World/park_randomization.h | 37 +++++++++++++ Engine/Game/World/park_rotation.h | 1 + .../Profile/Test/profile_change_tests.cpp | 17 ++++++ Extension/Profile/local_profile.h | 1 + Extension/Profile/local_profile_runtime.h | 2 + Extension/Profile/local_profile_store.cpp | 11 ++++ Extension/Profile/local_profile_world.cpp | 42 +++++++++++++++ Extension/UI/Overlay/skate_menu_world.cpp | 18 +++++++ Extension/World/console_commands.cpp | 26 ++++++++++ Extension/World/local_park_rotation.cpp | 4 ++ Extension/World/local_park_rotation.h | 2 + README.md | 11 ++++ Server/README.txt | 1 + Server/server_host.cpp | 7 ++- 16 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 Engine/Game/World/Test/park_randomization_tests.cpp create mode 100644 Engine/Game/World/park_randomization.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 63b78db..6c2700e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,11 @@ endif() option(DINGOSDK_BUILD_TESTS "Build logging regression tests" OFF) option(DINGOSDK_BUILD_PARK_EDITOR_TESTS "Build park editor regression tests" OFF) option(DINGOSDK_BUILD_TRAINER_TESTS "Build the trainer's tools and regression tests" OFF) +if(DINGOSDK_BUILD_PARK_EDITOR_TESTS) + enable_testing() + add_executable(dingosdk_park_randomization_tests Engine/Game/World/Test/park_randomization_tests.cpp) + add_test(NAME park_randomization COMMAND dingosdk_park_randomization_tests) +endif() if(DINGOSDK_BUILD_PARK_EDITOR_TESTS AND WIN32) enable_testing() add_executable(dingosdk_park_editor_tests Extension/Objects/ParkEditor/Test/park_editor_tests.cpp) diff --git a/Engine/Game/World/Test/park_randomization_tests.cpp b/Engine/Game/World/Test/park_randomization_tests.cpp new file mode 100644 index 0000000..b990452 --- /dev/null +++ b/Engine/Game/World/Test/park_randomization_tests.cpp @@ -0,0 +1,52 @@ +#include "Engine/Game/World/park_randomization.h" +#include +#include +#include + +namespace { +int failures{}; +void check(bool condition, const char* message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +} +int main() { + using namespace dingosdk; + std::mt19937 generator{12345}; + std::array, park_lots.size()> seen; + constexpr unsigned rolls = 30000; + for (unsigned roll = 0; roll < rolls; ++roll) { + const auto choices = random_park_choices(generator); + for (unsigned lot = 0; lot < choices.size(); ++lot) { + check(!choices[lot].empty() && choices[lot] != "empty" && valid_park(lot, choices[lot]), + "random layouts are non-empty and supported by their lot"); + ++seen[lot][choices[lot]]; + } + } + for (unsigned lot = 0; lot < park_lots.size(); ++lot) { + std::set expected; + for (unsigned family = 0; family < park_families.size(); ++family) + for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant) + expected.insert(park_id(family, variant)); + check(seen[lot].size() == expected.size(), "every supported layout can be selected"); + const auto average = rolls / static_cast(expected.size()); + for (const auto& layout : expected) { + const auto count = seen[lot][layout]; + // Broad deterministic bound catches equal-family weighting without + // making this a fragile statistical test or fixing a particular RNG sequence. + check(count > average * 3 / 4 && count < average * 5 / 4, + "layouts have equal weight regardless of family size"); + } + } + ParkLaunchRandomization launch; + check(!launch.consume(true, false), "launch randomization is off by default"); + launch.pending = true; + check(!launch.consume(false, false) && launch.pending, "wait for park content before consuming launch"); + check(launch.consume(true, false) && !launch.pending, "randomize once when the controller becomes ready"); + check(!launch.consume(false, false) && !launch.consume(true, false), "map reloads do not reroll"); + launch.pending = true; + check(!launch.consume(true, true) && !launch.pending, "guests never randomize host-controlled parks"); + check(!launch.consume(true, false), "leaving a host does not defer startup randomization"); + if (failures) return 1; + std::cout << "park randomization: ok\n"; + return 0; +} diff --git a/Engine/Game/World/park_randomization.h b/Engine/Game/World/park_randomization.h new file mode 100644 index 0000000..5c0ad53 --- /dev/null +++ b/Engine/Game/World/park_randomization.h @@ -0,0 +1,37 @@ +#pragma once +#include "park_rotation.h" +#include +#include + +namespace dingosdk { +// Sample layouts, rather than families, so every authored layout has equal weight. +// Empty lots are deliberately excluded from the random pool. +template ParkChoices random_park_choices(Generator& generator) { + ParkChoices choices; + for (unsigned lot = 0; lot < park_lots.size(); ++lot) { + unsigned count{}; + for (const auto variants : park_lots[lot].counts) count += variants; + auto selected = std::uniform_int_distribution(0, count - 1)(generator); + for (unsigned family = 0; family < park_families.size(); ++family) { + const auto variants = park_lots[lot].counts[family]; + if (selected < variants) { choices[lot] = park_id(family, selected + 1); break; } + selected -= variants; + } + } + return choices; +} +inline ParkChoices random_park_choices() { + thread_local std::mt19937 generator{std::random_device{}()}; + return random_park_choices(generator); +} + +struct ParkLaunchRandomization { + bool pending{}; + // Waiting for content does not consume the launch. Joining a host does: + // leaving that session or reconstructing the controller must not reroll. + bool consume(bool ready, bool controlled_by_host) { + if (!ready || !std::exchange(pending, false)) return false; + return !controlled_by_host; + } +}; +} diff --git a/Engine/Game/World/park_rotation.h b/Engine/Game/World/park_rotation.h index 54f23c3..357c563 100644 --- a/Engine/Game/World/park_rotation.h +++ b/Engine/Game/World/park_rotation.h @@ -49,6 +49,7 @@ inline std::string park_label(std::string_view id) { } struct ParksModel { bool available{}, ready{}, controlled_by_host{}; + bool randomize_on_launch{}; ParkChoices choices; std::string feedback; }; diff --git a/Extension/Profile/Test/profile_change_tests.cpp b/Extension/Profile/Test/profile_change_tests.cpp index 4fe8c04..6066069 100644 --- a/Extension/Profile/Test/profile_change_tests.cpp +++ b/Extension/Profile/Test/profile_change_tests.cpp @@ -43,6 +43,21 @@ int main() { check(announced(), "a profile option save is announced"); store.set_selected_cosmetic_preset(3); check(announced(), "a save outside the settings is announced"); + const ParkChoices parks{"megapark_04", "flumppark_10", "streetpark_06"}; + store.save_park_choices(parks); + check(announced() && profile::park_choices(store.snapshot()) == parks, "all park slots save together"); + store.save_park_choices(parks); + check(!announced(), "saving the same park choices announces nothing"); + auto invalid = parks; + invalid[0] = "skatepark_01"; + invalid[2] = "flumppark_10"; // Historic-only variant, invalid for Financial. + bool rejected{}; + try { store.save_park_choices(invalid); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && !announced() && profile::park_choices(store.snapshot()) == parks, + "an invalid batch never saves earlier slots"); + check(!store.user_value("ReSkate.RandomizeParksOnLaunch"), "existing profiles have no launch randomization preference"); + store.set_user_value("ReSkate.RandomizeParksOnLaunch", true); + check(announced(), "the launch preference is saved and announced"); } { // A restart: the saved values and their types (a whole number saved as a float stays one). @@ -52,6 +67,8 @@ int main() { store.user_value("enableCompass") == Json(false) && store.user_value("cameraMode") == Json(2) && number && number->is_number_float() && number->get() == 20.0 && store.selected_cosmetic_preset() == 3, "saved settings come back after a restart"); + check(profile::park_choices(store.snapshot()) == ParkChoices{"megapark_04", "flumppark_10", "streetpark_06"} && + store.user_value("ReSkate.RandomizeParksOnLaunch") == Json(true), "parks and launch preference survive a restart"); } std::filesystem::remove_all(folder, error); if (failures) return 1; diff --git a/Extension/Profile/local_profile.h b/Extension/Profile/local_profile.h index 23d4e29..82b2775 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -175,6 +175,7 @@ public: std::uint32_t selected_cosmetic_preset() const; void set_selected_cosmetic_preset(std::uint32_t); void save_park_choice(unsigned lot, std::string_view id); + void save_park_choices(const ParkChoices&); void save_world_controls(const WorldControls&); void save_graphics_controls(const GraphicsControls&); std::uint32_t noclip_binding() const; diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 1758638..fc7d85d 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -91,6 +91,8 @@ PlayerCardModel local_profile_player_card(); bool set_local_player_card_name(std::string_view name); ParksModel local_profile_parks(); bool set_local_park(std::string_view lot, std::string_view layout); +bool load_random_local_parks(); +bool set_local_park_randomize_on_launch(bool enabled); // Transient lobby authority. Guest choices never overwrite saved preferences. void set_lobby_park_mode(bool active, bool guest); void apply_host_park_choices(const ParkChoices& choices); diff --git a/Extension/Profile/local_profile_store.cpp b/Extension/Profile/local_profile_store.cpp index a040226..ec93217 100644 --- a/Extension/Profile/local_profile_store.cpp +++ b/Extension/Profile/local_profile_store.cpp @@ -253,4 +253,15 @@ void Store::save_park_choice(unsigned lot, std::string_view id) { } +void Store::save_park_choices(const ParkChoices& choices) { + for (unsigned lot = 0; lot < choices.size(); ++lot) + require(valid_park(lot, choices[lot]), "Unknown park layout for this lot"); + std::lock_guard lock(mutex_); + if (park_choices(value_) == choices) return; + Update update(*this); + for (unsigned lot = 0; lot < choices.size(); ++lot) + update.json(value_.settings, {"parks", park_lots[lot].key}) = choices[lot]; + update.commit(); +} + } diff --git a/Extension/Profile/local_profile_world.cpp b/Extension/Profile/local_profile_world.cpp index 1d96e80..11f3d1f 100644 --- a/Extension/Profile/local_profile_world.cpp +++ b/Extension/Profile/local_profile_world.cpp @@ -223,6 +223,7 @@ bool set_local_park(std::string_view lot, std::string_view layout) { } const auto index = static_cast(it - park_lots.begin()); s.store->save_park_choice(index, layout); + r.launch_randomization.pending = false; r.model.choices[index] = layout; r.sent[index].clear(); r.model.feedback = "Park choice saved. It loads when BAM's park controller is ready."; @@ -232,6 +233,47 @@ bool set_local_park(std::string_view lot, std::string_view layout) { return false; } } +bool load_random_local_parks() { + auto& s = local_runtime(); auto& r = park_runtime(); + std::lock_guard lock(s.native_mutex); + if (!s.active || !r.active || !s.store) return false; + if (r.model.controlled_by_host) { + r.model.feedback = "The lobby host controls community park layouts."; + return false; + } + try { + auto choices = random_park_choices(); + // Publish only after all three choices are durably saved together. + s.store->save_park_choices(choices); + r.model.choices = std::move(choices); + r.sent = {}; r.clear_unset = {}; + r.launch_randomization.pending = false; + // Respect an in-flight clear/load delay; the pending load uses the new choice. + if (r.pending_lot >= park_lots.size()) r.next_request = 0; + r.model.feedback = "Random park choices saved. Loading when the park controller is ready."; + return true; + } catch (...) { + r.model.feedback = "Random park choices could not be saved."; + return false; + } +} +bool set_local_park_randomize_on_launch(bool enabled) { + auto& s = local_runtime(); auto& r = park_runtime(); + std::lock_guard lock(s.native_mutex); + if (!s.active || !r.active || !s.store) return false; + try { + s.store->set_user_value("ReSkate.RandomizeParksOnLaunch", enabled); + r.model.randomize_on_launch = enabled; + // Enabling schedules the next launch, never an unexpected mid-session load. + if (!enabled) r.launch_randomization.pending = false; + r.model.feedback = enabled ? "Parks will randomize on the next game launch when you control the layouts." : + "Randomize on Launch is off. Your saved layouts will be used."; + return true; + } catch (...) { + r.model.feedback = "Randomize on Launch could not be saved."; + return false; + } +} void set_lobby_park_mode(bool active, bool guest) { auto& s = local_runtime(); auto& r = park_runtime(); std::lock_guard lock(s.native_mutex); diff --git a/Extension/UI/Overlay/skate_menu_world.cpp b/Extension/UI/Overlay/skate_menu_world.cpp index e937e57..c238371 100644 --- a/Extension/UI/Overlay/skate_menu_world.cpp +++ b/Extension/UI/Overlay/skate_menu_world.cpp @@ -302,6 +302,24 @@ void parks(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { : model.parks.controlled_by_host ? "You are an admin of this server: a layout you load changes for everyone." : "Choose a layout for each slot. Choices save and are shared when you host."); note("Construction controls both the construction site and the Hedgemont park."); + const auto park_command = [&](const char* command) { + std::array result{}; + callbacks.queue_console_command(callbacks.user, command, result.data(), result.size()); + result.back() = '\0'; feedback(menu, result.data()); + }; + ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command); + if (ImGui::Button("Load Random Parks")) { + park_command("park random"); + menu.park_edit = {}; // Discard staged dropdown edits, including slots that reroll the same layout. + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!model.parks.available || !callbacks.queue_console_command); + auto random_on_launch = model.parks.randomize_on_launch; + if (ImGui::Checkbox("Randomize on Launch", &random_on_launch)) + park_command(random_on_launch ? "park random-on-launch 1" : "park random-on-launch 0"); + ImGui::EndDisabled(); + note("Pick a random layout for each slot. Randomize on Launch runs once per game launch; multiplayer follows the host."); ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command); if (ImGui::BeginTable("park-layouts", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Location", ImGuiTableColumnFlags_WidthStretch, 0.9f); diff --git a/Extension/World/console_commands.cpp b/Extension/World/console_commands.cpp index 7a8a09c..6831e49 100644 --- a/Extension/World/console_commands.cpp +++ b/Extension/World/console_commands.cpp @@ -248,6 +248,32 @@ void register_world_commands(Commands ®istry) { out((saved ? "" : "error: ") + local_profile_world_layers().feedback); }; registry.add(std::move(defaults)); + auto random_parks = action("park random", "Load a random layout in every park slot", Group::world); + random_parks.inspect = [](const Model &m) { + return State{m.parks.available && (!m.parks.controlled_by_host || m.multiplayer.server_admin), {}, + "The host controls park layouts, or park controls are unavailable.", {}, false}; + }; + random_parks.run = [](const Model &, const Values &, const Output &out) { + if (multiplayer::model().server_admin) { + const bool sent = multiplayer::queue_command("server", "park random", ""); + out(sent ? "Sent to the server." : "error: The request queue is busy; try again."); + return; + } + const bool saved = load_random_local_parks(); + out((saved ? "" : "error: ") + local_profile_parks().feedback); + }; + registry.add(std::move(random_parks)); + auto random_on_launch = variable("park random-on-launch", "Randomize local parks on the next game launch", + Group::world, argument("0|1", Type::boolean)); + random_on_launch.inspect = [](const Model &m) { + return boolean_state(m.parks.available, m.parks.randomize_on_launch, "Park controls are unavailable.", + "Local preference; the host still controls multiplayer layouts"); + }; + random_on_launch.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_park_randomize_on_launch(std::get(args[0])); + out((saved ? "" : "error: ") + local_profile_parks().feedback); + }; + registry.add(std::move(random_on_launch)); for (unsigned lot = 0; lot < park_lots.size(); ++lot) { auto layout = argument("layout|empty"); layout.choices = {"empty"}; diff --git a/Extension/World/local_park_rotation.cpp b/Extension/World/local_park_rotation.cpp index 2ceb60b..0e18d55 100644 --- a/Extension/World/local_park_rotation.cpp +++ b/Extension/World/local_park_rotation.cpp @@ -114,6 +114,8 @@ void update_park_rotation(std::uintptr_t manager, std::uint64_t now) { if (!park_owner_ready(manager, context)) { reset_park_session(); return; } if (r.owner != manager || r.context != context) reset_park_session(); r.owner = manager; r.context = context; r.model.ready = true; + if (r.launch_randomization.consume(r.model.ready, r.model.controlled_by_host)) + load_random_local_parks(); // Serialize clear/load across ticks. The native graph's empty-name branch // unloads every family at this lot, including a previously selected family. if (now < r.next_request) return; @@ -180,6 +182,8 @@ void start_park_rotation() noexcept { std::vector created; try { r.model.choices = profile::park_choices(*local_runtime().store->shared_snapshot()); + r.model.randomize_on_launch = local_preference("RandomizeParksOnLaunch").value_or(false); + r.launch_randomization.pending = r.model.randomize_on_launch; for (const auto& site : sites) { std::array bytes{}; if (!read(base + site.rva, bytes) || bytes != site.bytes) diff --git a/Extension/World/local_park_rotation.h b/Extension/World/local_park_rotation.h index 5badb00..67fde31 100644 --- a/Extension/World/local_park_rotation.h +++ b/Extension/World/local_park_rotation.h @@ -1,5 +1,6 @@ #pragma once #include "Extension/Profile/runtime_internal.h" +#include "Engine/Game/World/park_randomization.h" namespace dingosdk::profile_runtime { struct ParkRuntime { @@ -12,6 +13,7 @@ struct ParkRuntime { Notify original_notify{}; std::atomic active{}; ParksModel model; + ParkLaunchRandomization launch_randomization; ParkChoices sent; bool lobby_active{}; std::array clear_unset{}; diff --git a/README.md b/README.md index 2bd7b2b..b603bca 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,17 @@ ReSkate supports one game build at a time (Steam build `25414733`). The menu and console keys can be changed in the launcher's Settings. +In **MAP → ROTATING PARKS**, **Load Random Parks** chooses and loads one random layout for each of +San Vansterdam's three slots. Each supported layout has an equal chance; empty lots are excluded and +a roll can pick a layout already loaded. Choices save with your profile and are shared when you host. +**Randomize on Launch** is off by default. Turn it on to pick fresh layouts once on each subsequent +game launch, when the park controller is ready. Changing maps does not roll again. Guests follow the +host's layouts; a dedicated server admin can use the button to randomize the server's parks for everyone. +The launch preference only affects your own parks, including when you host a lobby. + +The console equivalents are `park random` and `park random-on-launch 0|1`. A dedicated server also +accepts `park random` from its console or an admin. + ### Where things are | What | Where | diff --git a/Server/README.txt b/Server/README.txt index 6cc89fa..a836fab 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -133,6 +133,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. tpall [player] Everyone to you (admins in game) or to a player. tphere One player to you (admins in game). park + park random Randomize all three park slots (excludes empty lots). layer-sync on|off layer default|on|off tod Time of day on every map (needs layer-sync on). diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 1a452eb..1cd3f5c 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -5,6 +5,7 @@ #include "Engine/Core/Text/word_filter.h" #include "Engine/Game/Build/supported_build.h" #include "Engine/Game/World/world_names.h" +#include "Engine/Game/World/park_randomization.h" #ifdef _WIN32 #include #include @@ -46,7 +47,7 @@ constexpr std::string_view help_text = "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" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" - "park | layer-sync on|off | layer default|on|off | tod \n" + "park | park random | layer-sync on|off | layer default|on|off | tod \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ]\n" "admin add|remove | admins | update | quit"; @@ -1194,6 +1195,10 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed("Deleted " + std::to_string(removed) + " placed object" + (removed == 1 ? "." : "s.")); } if (name == "park") { + if (lower(argument) == "random") { + config_.parks = random_park_choices(); + return changed("Random layouts selected for every park slot."); + } const auto [lot_name, layout] = split(argument); const auto lot = std::find_if(park_lots.begin(), park_lots.end(), [&](const auto &l) { return l.key == lot_name; }); if (lot == park_lots.end()) return "park construction|historic|financial "; From 1715dd38d049e6961624f6e8f59036f20a466c56 Mon Sep 17 00:00:00 2001 From: ComputerKWasTaken Date: Mon, 5 Oct 2026 15:44:58 -0500 Subject: [PATCH 2/2] Add controller park randomization and cancel guest startup rolls --- .../World/Test/park_randomization_tests.cpp | 4 ++ Engine/Game/World/park_randomization.h | 3 +- Extension/Multiplayer/Test/mesh_tests.cpp | 63 ++++++++++++++++- Extension/Multiplayer/Test/protocol_tests.cpp | 19 ++++++ Extension/Profile/local_profile_world.cpp | 3 + .../Test/native_tools_view_tests.cpp | 67 ++++++++++++++++++- Extension/UI/NativeMenu/native_tools_view.h | 16 ++++- README.md | 7 +- 8 files changed, 173 insertions(+), 9 deletions(-) diff --git a/Engine/Game/World/Test/park_randomization_tests.cpp b/Engine/Game/World/Test/park_randomization_tests.cpp index b990452..bc55f20 100644 --- a/Engine/Game/World/Test/park_randomization_tests.cpp +++ b/Engine/Game/World/Test/park_randomization_tests.cpp @@ -46,6 +46,10 @@ int main() { launch.pending = true; check(!launch.consume(true, true) && !launch.pending, "guests never randomize host-controlled parks"); check(!launch.consume(true, false), "leaving a host does not defer startup randomization"); + launch.pending = true; + check(!launch.consume(false, true) && !launch.pending, + "joining a host cancels startup randomization before park content is ready"); + check(!launch.consume(true, false), "leaving an unready host session cannot trigger a deferred roll"); if (failures) return 1; std::cout << "park randomization: ok\n"; return 0; diff --git a/Engine/Game/World/park_randomization.h b/Engine/Game/World/park_randomization.h index 5c0ad53..912f296 100644 --- a/Engine/Game/World/park_randomization.h +++ b/Engine/Game/World/park_randomization.h @@ -30,8 +30,9 @@ struct ParkLaunchRandomization { // Waiting for content does not consume the launch. Joining a host does: // leaving that session or reconstructing the controller must not reroll. bool consume(bool ready, bool controlled_by_host) { + if (controlled_by_host) { pending = false; return false; } if (!ready || !std::exchange(pending, false)) return false; - return !controlled_by_host; + return true; } }; } diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index e0895a8..732e884 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -6,19 +6,25 @@ #include "Extension/Multiplayer/Session/session_receive.cpp" #include "Extension/Multiplayer/Session/session_commands.cpp" #include "Extension/Multiplayer/Session/session_party.cpp" +#include "Engine/Game/World/park_randomization.h" #include #include namespace dingosdk { bool read_local_camera_transform(std::uintptr_t, std::uintptr_t, std::array&) noexcept { return false; } -ParksModel local_profile_parks() { return {}; } +ParksModel simulated_parks; +std::vector>* simulated_park_receives{}; +std::uint64_t simulated_park_receiver{}; +ParksModel local_profile_parks() { return simulated_parks; } void set_lobby_park_mode(bool, bool) {} bool simulated_object_guest{}; void set_lobby_object_guest(bool guest) { simulated_object_guest = guest; } WorldLayersModel simulated_host_layers; WorldLayersModel local_profile_world_layers() { return simulated_host_layers; } void apply_host_world_layers(bool, const WorldLayerChoices &) {} -void apply_host_park_choices(const ParkChoices &) {} +void apply_host_park_choices(const ParkChoices& choices) { + if (simulated_park_receives) simulated_park_receives->emplace_back(simulated_park_receiver, choices); +} std::optional capture_local_network_objects() { return {}; } void set_remote_network_objects(std::string_view, std::span) {} void clear_remote_network_objects() {} @@ -520,6 +526,7 @@ struct Simulation { local.pose.root.position[0] = static_cast(now - 10000000) / 1000000; local.pose.root.position[2] = n < positions.size() ? positions[n] : static_cast(n); refresh_host_choices(s, now); + simulated_park_receiver = s.transport.status().local_id; networking(s, local, now); if (s.mode != Mode::off) { sync_objects(s, local, now); @@ -2053,6 +2060,54 @@ void mesh_checks() { for (const auto &s : sim.nodes) check(s->mode == Mode::off, "Guest remained after host departure"); } +void random_park_sync_checks() { + std::mt19937 generator{7821}; + std::vector> received; + simulated_park_receives = &received; + simulated_parks.choices = random_park_choices(generator); + Simulation sim; + for (unsigned i = 0; i < 3; ++i) sim.add(); + sim.run(60); sim.fresh(3); + const auto all_received = [&] { + return !received.empty() && std::all_of(received.begin(), received.end(), [](const auto& entry) { + return entry.second == simulated_parks.choices; + }); + }; + const auto received_by = [&](const Session& node) { + return std::any_of(received.begin(), received.end(), [&](const auto& entry) { + return entry.first == node.transport.status().local_id && entry.second == simulated_parks.choices; + }); + }; + check(received_by(*sim.nodes[1]) && received_by(*sim.nodes[2]) && all_received(), + "Guests did not receive the host's initial random park choices"); + received.clear(); + simulated_parks.choices = random_park_choices(generator); + sim.run(12); sim.fresh(3); + check(received_by(*sim.nodes[1]) && received_by(*sim.nodes[2]) && all_received(), + "A new host roll did not reach existing guests intact"); + received.clear(); + sim.add(); sim.run(30); sim.fresh(4); + check(received_by(*sim.nodes.back()) && all_received(), + "A late joiner did not receive the host's current random parks"); + + auto& host = *sim.nodes[0]; + auto& sender = *sim.nodes[2]; + auto forged = packet(host, PacketKind::roster, sim.now); + forged.parks = random_park_choices(generator); + check(forged.parks != simulated_parks.choices, "Spoof fixture must use different park choices"); + forged.source = sender.transport.status().local_id; + forged.epoch = sender.epoch; + for (const auto& node : sim.nodes) + forged.members.push_back({node->transport.status().local_id, node->epoch, "Player"}); + received.clear(); + check(send_packet(sender, sim.nodes[1]->transport.status().local_id, forged, true, false), "Guest park spoof fixture failed to send"); + sim.run(2); sim.fresh(4); + check(std::all_of(received.begin(), received.end(), [&](const auto& entry) { return entry.second == simulated_parks.choices; }) && + host.parks == simulated_parks.choices, "A guest's forged roster replaced the host's park choices"); + simulated_park_receives = nullptr; + simulated_parks = {}; + std::cout << "Random parks: host rolls, current guests, late join and guest spoof rejection passed.\n"; +} } // namespace } // namespace dingosdk::multiplayer int main(int argc, char **argv) { @@ -2069,6 +2124,9 @@ int main(int argc, char **argv) { dingosdk::simulated_host_layers = {}; dingosdk::multiplayer::join_tick_checks(); dingosdk::multiplayer::tick_settings_checks(); + if (argc == 2 && std::string_view(argv[1]) == "--parks-only") { + dingosdk::multiplayer::random_park_sync_checks(); return 0; + } if (argc == 2 && std::string_view(argv[1]) == "--objects-only") { dingosdk::multiplayer::object_sync_checks(); return 0; } @@ -2110,6 +2168,7 @@ int main(int argc, char **argv) { dingosdk::multiplayer::session_controls_checks(); dingosdk::multiplayer::guest_building_checks(); dingosdk::multiplayer::world_layer_sync_checks(); + dingosdk::multiplayer::random_park_sync_checks(); std::cout << "Mesh admission, 2-8 players, relay fallback, route loss, packet loss, reconnect and " "password checks passed.\n"; return 0; diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 223296d..a883970 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -3,6 +3,7 @@ #include "Extension/Multiplayer/Net/protocol.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Extension/Throwdowns/throwdown_wire.h" +#include "Engine/Game/World/park_randomization.h" #include #include #include @@ -47,6 +48,23 @@ void tick_rates_codec() { } } } +void random_parks_codec() { + std::mt19937 generator{9147}; + auto p = frame(1, 0); + p.kind = PacketKind::roster; + p.members = {{76561198000000001ULL, p.epoch, "Host"}}; + for (unsigned roll = 0; roll < 1000; ++roll) { + p.parks = dingosdk::random_park_choices(generator); + const auto decoded = decode_wire(encode_wire(p)); + check(decoded && decoded->parks == p.parks, "A random park selection changed in the host roster"); + } + // This variant exists at Historic, but not Construction: lot-specific + // validation must also hold when sharing randomized layouts. + p.parks[0] = "flumppark_10"; + bool rejected{}; + try { (void)encode(p); } catch (const std::invalid_argument&) { rejected = true; } + check(rejected, "A park layout for another lot was shared"); +} void compressed_codec() { auto p = frame(1, 125); p.time_us = 1000000; @@ -909,6 +927,7 @@ int main() { try { codec(); tick_rates_codec(); + random_parks_codec(); compressed_codec(); sender_timeline(); greetings(); diff --git a/Extension/Profile/local_profile_world.cpp b/Extension/Profile/local_profile_world.cpp index 11f3d1f..4d24bea 100644 --- a/Extension/Profile/local_profile_world.cpp +++ b/Extension/Profile/local_profile_world.cpp @@ -279,6 +279,9 @@ void set_lobby_park_mode(bool active, bool guest) { std::lock_guard lock(s.native_mutex); if (r.lobby_active == active && r.model.controlled_by_host == (active && guest)) return; if (!s.active.load(std::memory_order_acquire) || !r.active.load(std::memory_order_acquire)) return; + // Joining a host cancels this launch's roll even before a park controller + // can tick. Leaving the lobby must restore saved choices without rerolling. + if (active && guest) r.launch_randomization.pending = false; auto choices = r.model.choices; if (!active) choices = profile::park_choices(*s.store->shared_snapshot()); else if (!guest) { diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 3b64308..8de6244 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -96,9 +96,9 @@ int main() { model.world_controls.choices.traffic = 3; activate(state, model, cb, "traffic", ""); check(capture.command == "environment traffic -1", "Population cycles back to authored default"); - const auto parks = render(state, model, cb, parks_section); - const auto load = std::find_if(parks.side.begin(), parks.side.end(), [](const auto& row) { return row.id == "load-park"; }); - check(load != parks.side.end() && load->command.empty(), "Host-owned park action is visibly unavailable"); + const auto locked_parks = render(state, model, cb, parks_section); + const auto load = std::find_if(locked_parks.side.begin(), locked_parks.side.end(), [](const auto& row) { return row.id == "load-park"; }); + check(load != locked_parks.side.end() && load->command.empty(), "Host-owned park action is visibly unavailable"); cb.queue_debug = [](void*, const overlay::DebugRequest&, char* result, std::size_t size) { std::snprintf(result, size, "Queued for the game update thread."); return true; }; @@ -122,6 +122,67 @@ int main() { model.player_card.custom_name.clear(); const auto steam_page = render(state, model, cb, player_section); check(find(steam_page.side, "card-name-reset")->command.empty(), "Steam name reset is idle without a custom name"); + { + State parks; + overlay::Model m; + m.world.map = WorldMap::bam; + m.parks.available = true; + m.parks.choices.fill("flumppark_01"); + Capture log; + auto controls = cb; + controls.user = &log; + const auto control = [&](const Page& page, std::string_view id) -> const Row& { + const auto found = find(page.main, id); + check(found != page.main.end() && found->button, "Random park controls are controller-focusable buttons"); + return *found; + }; + auto page = render(parks, m, controls, parks_section); + check(control(page, "load-random-parks").title == "Load Random Parks" && + control(page, "load-random-parks").command == "load-random-parks", "Controller exposes random loading for all lots"); + check(control(page, "park-random-on-launch").title == "Randomize on Launch: Off", "Launch toggle reads the shared preference"); + parks.parks.fill("megapark_01"); + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 1 && log.command == "park random", "Random loading queues one shared command, not three separate rolls"); + check(parks.parks == ParkChoices{}, "Accepted random loading discards every staged layout"); + sync(parks, m); + check(parks.parks == m.parks.choices, "A reroll of the same saved layouts still replaces staged selections"); + + m.parks.controlled_by_host = true; + page = render(parks, m, controls, parks_section); + check(control(page, "load-random-parks").command.empty(), "Guests cannot activate random loading"); + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 1, "A stale random button cannot load after joining a host"); + activate(parks, m, controls, "park-random-on-launch", {}); + check(log.calls == 2 && log.command == "park random-on-launch 1", "Guests can save their own next-launch preference"); + m.parks.randomize_on_launch = true; + page = render(parks, m, controls, parks_section); + check(control(page, "park-random-on-launch").title == "Randomize on Launch: On", "Controller reflects changes from the other menu"); + activate(parks, m, controls, "park-random-on-launch", {}); + check(log.calls == 3 && log.command == "park random-on-launch 0", "Launch toggle uses the latest shared state"); + + m.multiplayer.server_admin = true; + page = render(parks, m, controls, parks_section); + check(!control(page, "load-random-parks").command.empty(), "Dedicated-server admins keep random park controls"); + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 4 && log.command == "park random", "Server admin sends one shared command for server-side routing"); + m.world.map = WorldMap::grom; + activate(parks, m, controls, "load-random-parks", {}); + check(log.calls == 4, "Random loading is unavailable on maps without these park lots"); + m.world.map = WorldMap::bam; + m.parks.available = false; + activate(parks, m, controls, "load-random-parks", {}); + activate(parks, m, controls, "park-random-on-launch", {}); + check(log.calls == 4, "Unavailable park controls cannot queue actions"); + m.parks.available = true; + controls.queue_console_command = nullptr; + page = render(parks, m, controls, parks_section); + check(control(page, "load-random-parks").command.empty() && control(page, "park-random-on-launch").command.empty(), + "Missing command callbacks disable both controls"); + controls.queue_console_command = [](void*, const char*, char*, std::size_t) { return false; }; + parks.parks.fill("megapark_01"); + activate(parks, m, controls, "load-random-parks", {}); + check(parks.parks[0] == "megapark_01" && !parks.feedback.empty(), "A rejected queue retains staged choices and reports failure"); + } { // Time of day forces one of a map's seven time layers, others off. overlay::Model world; diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index 49d93e7..59b3308 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -137,7 +137,9 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks s.feedback = accepted ? "" : "Couldn't apply that change. Try again when the game is ready."; }; const auto console = [&](const std::string& text, bool enabled) { - report(enabled && cb.queue_console_command && cb.queue_console_command(cb.user, text.c_str(), result.data(), result.size())); + const bool accepted = enabled && cb.queue_console_command && cb.queue_console_command(cb.user, text.c_str(), result.data(), result.size()); + report(accepted); + return accepted; }; if (command == "select-level") { if (multiplayer_controls_level(m.multiplayer)) { s.feedback = "Only the lobby host can change levels."; return; } @@ -170,6 +172,14 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks s.parks[s.lot] = family == park_families.size() ? "empty" : park_id(family, 1); } else if (command == "load-park") { console("park " + std::string(park_lots[s.lot].key) + " " + s.parks[s.lot], can_park(m, cb) && valid_park(s.lot, s.parks[s.lot])); + } else if (command == "load-random-parks") { + // Use the shared command path: hosts load locally, server admins request + // one server roll, and guests cannot replace the host's layouts. + if (console("park random", can_park(m, cb))) s.parks = {}; + } else if (command == "park-random-on-launch") { + // This is a personal preference, including while following a host. + console(m.parks.randomize_on_launch ? "park random-on-launch 0" : "park random-on-launch 1", + m.parks.available); } else if (command == "noclip" || command == "no-bail" || command == "freecam" || command == "speed") { const auto& d = m.debug; overlay::DebugRequest request; @@ -264,6 +274,10 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 for (unsigned i = 0; i < park_lots.size(); ++i) button(p.main, "park-lot-" + std::to_string(i), std::string(park_lots[i].label) + (s.lot == i ? " / SELECTED" : ""), "park-lot", true, std::string(park_lots[i].key)); + text(p.main, "park-random-title", "ALL THREE PARK LOCATIONS"); + button(p.main, "load-random-parks", "Load Random Parks", "load-random-parks", can_park(m, cb)); + toggle(p.main, "park-random-on-launch", "Randomize on Launch", m.parks.randomize_on_launch, + m.parks.available && cb.queue_console_command); text(p.side, "park-options", "PARK LAYOUT"); const bool available = can_park(m, cb); std::string family = "Empty lot"; diff --git a/README.md b/README.md index b603bca..91e25c1 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,16 @@ ReSkate supports one game build at a time (Steam build `25414733`). The menu and console keys can be changed in the launcher's Settings. -In **MAP → ROTATING PARKS**, **Load Random Parks** chooses and loads one random layout for each of +In **MAP → ROTATING PARKS** and the controller-friendly **CUSTOM STUFF → PARKS** screen, +**Load Random Parks** chooses and loads one random layout for each of San Vansterdam's three slots. Each supported layout has an equal chance; empty lots are excluded and a roll can pick a layout already loaded. Choices save with your profile and are shared when you host. **Randomize on Launch** is off by default. Turn it on to pick fresh layouts once on each subsequent game launch, when the park controller is ready. Changing maps does not roll again. Guests follow the host's layouts; a dedicated server admin can use the button to randomize the server's parks for everyone. -The launch preference only affects your own parks, including when you host a lobby. +Both menus share the same launch preference. It only affects your own parks, including when you host +a lobby. Joining a host cancels any pending startup roll, so leaving the lobby restores your saved +layouts without unexpectedly randomizing them. The console equivalents are `park random` and `park random-on-launch 0|1`. A dedicated server also accepts `park random` from its console or an admin.