Merge pull request #43 from ComputerKWasTaken/codex/random-community-parks

Add random community park loading and launch randomization
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-08 21:20:47 -05:00
commit 2c90fbec36
20 files changed
+406 -7

No files matched your search

+5
View File
@@ -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)
@@ -0,0 +1,56 @@
#include "Engine/Game/World/park_randomization.h"
#include <iostream>
#include <map>
#include <set>
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<std::map<std::string, unsigned>, 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<std::string> 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<unsigned>(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;
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "park_rotation.h"
#include <random>
#include <utility>
namespace dingosdk {
// Sample layouts, rather than families, so every authored layout has equal weight.
// Empty lots are deliberately excluded from the random pool.
template<class Generator> 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<unsigned>(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;
}
};
}
+1
View File
@@ -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;
};
+61 -2
View File
@@ -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 <iostream>
#include <set>
namespace dingosdk {
bool read_local_camera_transform(std::uintptr_t, std::uintptr_t, std::array<float, 16>&) noexcept { return false; }
ParksModel local_profile_parks() { return {}; }
ParksModel simulated_parks;
std::vector<std::pair<std::uint64_t, ParkChoices>>* 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<NetworkObjectSnapshot> capture_local_network_objects() { return {}; }
void set_remote_network_objects(std::string_view, std::span<const NetworkObjectOwner>) {}
void clear_remote_network_objects() {}
@@ -533,6 +539,7 @@ struct Simulation {
local.pose.root.position[0] = static_cast<float>(now - 10000000) / 1000000;
local.pose.root.position[2] = n < positions.size() ? positions[n] : static_cast<float>(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<std::pair<std::uint64_t, ParkChoices>> 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;
@@ -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 <chrono>
#include <cmath>
#include <iostream>
@@ -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();
@@ -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<double>() == 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;
+1
View File
@@ -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;
@@ -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);
+11
View File
@@ -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();
}
}
+45
View File
@@ -223,6 +223,7 @@ bool set_local_park(std::string_view lot, std::string_view layout) {
}
const auto index = static_cast<unsigned>(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) {
@@ -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;
+15 -1
View File
@@ -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";
+18
View File
@@ -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<char, 512> 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);
+26
View File
@@ -248,6 +248,32 @@ void register_world_commands(Commands &registry) {
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<bool>(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"};
+4
View File
@@ -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<void*> 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<unsigned char, 16> bytes{};
if (!read(base + site.rva, bytes) || bytes != site.bytes)
+2
View File
@@ -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<bool> active{};
ParksModel model;
ParkLaunchRandomization launch_randomization;
ParkChoices sent;
bool lobby_active{};
std::array<bool, park_lots.size()> clear_unset{};
+14
View File
@@ -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 |
+1
View File
@@ -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 <player> One player to you (admins in game).
park <construction|historic|financial> <layout>
park random Randomize all three park slots (excludes empty lots).
layer-sync on|off layer <key> default|on|off
layers <key>=<mode> ... Several world layers at once, each default, on or off.
tod <default|morning|noon|afternoon|evening|night|weatherday|weathernight>
+6 -1
View File
@@ -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 <Windows.h>
#include <bcrypt.h>
@@ -50,7 +51,7 @@ constexpr std::string_view help_text =
"placement everyone|admins|nobody | objects <number>|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
"tpall [player] | tphere <player> | votes [map|kick|tod on|off|<percent>] | vote-cancel\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
"park <lot> <layout> | layer-sync on|off | layer <key> default|on|off | tod <time|default>\n"
"park <lot> <layout> | park random | layer-sync on|off | layer <key> default|on|off | tod <time|default>\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 [<fingerprint>|remove <fingerprint>]\n"
"reserved [slots <n> | add|remove <SteamID64>] | admin add|remove <SteamID64> | 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 <layout, e.g. skatepark_01, or empty>";