diff --git a/CMakeLists.txt b/CMakeLists.txt index 50afea5..6d93a4e 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..bc55f20 --- /dev/null +++ b/Engine/Game/World/Test/park_randomization_tests.cpp @@ -0,0 +1,56 @@ +#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"); + 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 new file mode 100644 index 0000000..912f296 --- /dev/null +++ b/Engine/Game/World/park_randomization.h @@ -0,0 +1,38 @@ +#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 (controlled_by_host) { pending = false; return false; } + if (!ready || !std::exchange(pending, false)) return false; + return true; + } +}; +} 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/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index e9b3893..5b074e5 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() {} @@ -533,6 +539,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); @@ -2172,6 +2179,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) { @@ -2188,6 +2243,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; } @@ -2229,6 +2287,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 8aed8e1..480c3b8 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; @@ -976,6 +994,7 @@ int main() { try { codec(); tick_rates_codec(); + random_parks_codec(); compressed_codec(); sender_timeline(); greetings(); diff --git a/Extension/Profile/Test/profile_change_tests.cpp b/Extension/Profile/Test/profile_change_tests.cpp index f939ecb..07c3b16 100644 --- a/Extension/Profile/Test/profile_change_tests.cpp +++ b/Extension/Profile/Test/profile_change_tests.cpp @@ -46,6 +46,21 @@ int main() { "the music shuffle preference is saved as a Boolean profile option"); 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). @@ -56,6 +71,8 @@ int main() { store.user_value("ReSkate.MusicShuffle") == Json(true) && 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 aec449a..17dcaf4 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -180,6 +180,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&); bool freecam_controller() const; diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index e8a902f..4564681 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -101,6 +101,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 2fc5d77..c655dbd 100644 --- a/Extension/Profile/local_profile_store.cpp +++ b/Extension/Profile/local_profile_store.cpp @@ -311,4 +311,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..4d24bea 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,11 +233,55 @@ 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); 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/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 b0e7ab9..464a595 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,20 @@ custom map and then join a server on another map, the screen can stay black The menu and console keys can be changed in the launcher's Settings. +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. +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. + ### Where things are | What | Where | diff --git a/Server/README.txt b/Server/README.txt index 5b74e0c..c6dda4c 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -272,6 +272,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 layers = ... Several world layers at once, each default, on or off. tod diff --git a/Server/server_host.cpp b/Server/server_host.cpp index bc1ed0b..862bc5c 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -8,6 +8,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 @@ -50,7 +51,7 @@ constexpr std::string_view help_text = "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" + "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" "reserved [slots | add|remove ] | admin add|remove | admins | update | quit"; @@ -2045,6 +2046,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 ";