mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 04:55:16 +02:00
Merge pull request #43 from ComputerKWasTaken/codex/random-community-parks
Add random community park loading and launch randomization
This commit is contained in:
20 files changed
+406
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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"};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>";
|
||||
|
||||
Reference in new issue
Block a user