mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 19:47:08 +02:00
2381 lines
128 KiB
C++
2381 lines
128 KiB
C++
// Exercises the real session admission/routing implementation with an in-memory
|
|
// Steam transport. Native actors/UI are stubbed; no game process is opened.
|
|
#include "Extension/Multiplayer/Session/session.cpp"
|
|
#include "Extension/Multiplayer/Session/session_view.cpp"
|
|
#include "Extension/Multiplayer/Session/session_send.cpp"
|
|
#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 <map>
|
|
#include <set>
|
|
|
|
namespace dingosdk {
|
|
bool read_local_camera_transform(std::uintptr_t, std::uintptr_t, std::array<float, 16>&) noexcept { return false; }
|
|
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& 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() {}
|
|
std::vector<std::pair<std::uint64_t, std::uint64_t>> removed_object_owners;
|
|
void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch) {
|
|
if (owner && epoch) removed_object_owners.emplace_back(owner, epoch);
|
|
}
|
|
std::string network_object_status() { return {}; }
|
|
bool simulated_placement_allowed = true;
|
|
void set_lobby_object_placement_allowed(bool allowed) { simulated_placement_allowed = allowed; }
|
|
unsigned simulated_object_limit{};
|
|
void set_lobby_object_limit(unsigned limit) noexcept { simulated_object_limit = limit; }
|
|
unsigned lobby_object_limit() noexcept { return simulated_object_limit; }
|
|
unsigned simulated_guest_wipes{};
|
|
bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; }
|
|
}
|
|
namespace dingosdk {
|
|
bool teleport_local_skater(const std::array<float, 3>&) { return true; }
|
|
ControllerBindingsModel local_profile_controller_bindings() { return {}; }
|
|
void update_board_lock(std::uintptr_t, std::uintptr_t, bool) noexcept {}
|
|
void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &,
|
|
const multiplayer::MarkStyles &) noexcept {}
|
|
void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &,
|
|
const multiplayer::MarkStyles &) noexcept {}
|
|
}
|
|
namespace dingosdk::multiplayer {
|
|
// The backend's lists are not read here: a check lists the players it means.
|
|
std::set<std::pair<std::uint64_t, IdentityList>> simulated_identities;
|
|
bool identity_listed(std::uint64_t id, IdentityList list) noexcept { return simulated_identities.contains({id, list}); }
|
|
bool local_allows_player_collision(std::uintptr_t, std::uintptr_t) noexcept { return false; }
|
|
void update_remote_collision(std::uintptr_t, std::uintptr_t, const Pose &, bool, std::uint64_t) noexcept {}
|
|
void expire_remote_collision(std::uintptr_t, std::uint64_t) noexcept {}
|
|
}
|
|
namespace dingosdk::mods {
|
|
// Every simulated player runs the same process, so they share one scoring state.
|
|
ScoringState simulated_scoring{true, 0, {}};
|
|
ScoringState scoring_state() noexcept { return simulated_scoring; }
|
|
}
|
|
namespace dingosdk::physics_tuning {
|
|
void prepare() noexcept {}
|
|
std::optional<Encoded> local_differences(std::uintptr_t, std::size_t) { return {}; }
|
|
void enforce(std::uintptr_t, std::uintptr_t, std::span<const std::uint8_t>) noexcept {}
|
|
void release(std::uintptr_t) noexcept {}
|
|
std::string status() { return {}; }
|
|
}
|
|
namespace dingosdk::profile_runtime {
|
|
void install_script_error_log(std::uintptr_t) noexcept {}
|
|
void install_board_wear_hold(std::uintptr_t) noexcept {}
|
|
bool simulated_profile_store_ready = true;
|
|
std::map<std::string, Json, std::less<>> simulated_profile;
|
|
bool local_profile_store_ready() noexcept { return simulated_profile_store_ready; }
|
|
std::optional<bool> local_preference(std::string_view key) noexcept {
|
|
const auto value = local_value(key);
|
|
if (value && value->is_boolean()) return value->get<bool>();
|
|
return {};
|
|
}
|
|
void set_local_preference(std::string_view key, bool value) noexcept {
|
|
set_local_values({{std::string(key), value}});
|
|
}
|
|
std::optional<Json> local_value(std::string_view key) noexcept {
|
|
if (!simulated_profile_store_ready) return {};
|
|
const auto it = simulated_profile.find("ReSkate." + std::string(key));
|
|
if (it == simulated_profile.end()) return {};
|
|
return it->second;
|
|
}
|
|
void set_local_values(const std::vector<std::pair<std::string, Json>>& values) noexcept {
|
|
if (!simulated_profile_store_ready) return;
|
|
for (const auto& [key, value] : values) simulated_profile["ReSkate." + key] = value;
|
|
}
|
|
}
|
|
|
|
namespace dingosdk::multiplayer {
|
|
// Voice capture and playback need audio devices; the mesh carries no voice here.
|
|
struct VoiceChat::Impl {};
|
|
VoiceChat::VoiceChat() = default;
|
|
VoiceChat::~VoiceChat() = default;
|
|
void VoiceChat::configure(VoiceSettings) {}
|
|
VoiceModel VoiceChat::model() const { return {}; }
|
|
void VoiceChat::mute(std::uint64_t, bool) {}
|
|
void VoiceChat::volume(std::uint64_t, float) {}
|
|
void VoiceChat::update(VoiceScene) {}
|
|
void VoiceChat::reset() {}
|
|
void VoiceChat::process_native(std::uintptr_t) noexcept {}
|
|
void VoiceChat::receive(const Packet&) {}
|
|
std::vector<VoiceData> VoiceChat::take_capture() { return {}; }
|
|
}
|
|
|
|
namespace dingosdk::multiplayer {
|
|
struct SimulatedNetwork {
|
|
static inline std::set<std::pair<std::uint64_t, std::uint64_t>> blocked;
|
|
static inline bool lose_unreliable{}, lose_reports{};
|
|
static inline bool slow_join{};
|
|
static inline bool cosmetics_first{}, audit_lanes{};
|
|
static inline std::uint64_t hold_control_to{};
|
|
static inline std::array<unsigned, 3> lane_sends{};
|
|
static inline std::map<std::uint64_t, std::uint64_t> queues;
|
|
// What a message's arrival time is (TransportMessage::arrived); 0: the transport does not say.
|
|
static inline std::uint64_t clock{};
|
|
static std::pair<std::uint64_t, std::uint64_t> pair(std::uint64_t a, std::uint64_t b) {
|
|
return {std::min(a, b), std::max(a, b)};
|
|
}
|
|
};
|
|
struct SteamTransport::Impl {
|
|
static inline std::uint64_t next_id = 76561198000000000ULL;
|
|
static inline std::map<std::uint64_t, Impl *> bus;
|
|
TransportStatus state;
|
|
unsigned capacity = max_players;
|
|
std::set<std::uint64_t> allowed;
|
|
struct Queued { TransportMessage message; TrafficLane lane{}; };
|
|
std::vector<Queued> inbox;
|
|
std::uint64_t host{}, unreliable{};
|
|
void link(std::uint64_t id) {
|
|
if (std::none_of(state.peers.begin(), state.peers.end(), [&](const auto &p) { return p.id == id; }))
|
|
state.peers.push_back({id, true});
|
|
}
|
|
};
|
|
SteamTransport::SteamTransport() : impl_(std::make_unique<Impl>()) {
|
|
impl_->state.local_id = ++Impl::next_id;
|
|
Impl::bus[impl_->state.local_id] = impl_.get();
|
|
}
|
|
SteamTransport::~SteamTransport() {
|
|
stop();
|
|
Impl::bus.erase(impl_->state.local_id);
|
|
}
|
|
bool SteamTransport::open() { return impl_->state.ready = true; }
|
|
bool SteamTransport::host(unsigned capacity) {
|
|
stop();
|
|
open();
|
|
if (capacity < 2 || capacity > max_players) return false;
|
|
impl_->capacity = capacity;
|
|
impl_->state.hosting = true;
|
|
return true;
|
|
}
|
|
bool SteamTransport::join(std::uint64_t id, std::uint32_t, std::uint16_t) {
|
|
if (SimulatedNetwork::slow_join) Sleep(2);
|
|
stop();
|
|
open();
|
|
impl_->host = id;
|
|
impl_->allowed.insert(id);
|
|
return connect_peer(id);
|
|
}
|
|
bool SteamTransport::connect_peer(std::uint64_t id) {
|
|
auto &p = *impl_;
|
|
const auto other = Impl::bus.find(id);
|
|
if (!p.allowed.contains(id) || other == Impl::bus.end() ||
|
|
SimulatedNetwork::blocked.contains(SimulatedNetwork::pair(id, p.state.local_id)))
|
|
return false;
|
|
auto &q = *other->second;
|
|
if (q.state.hosting && q.state.peers.size() >= q.capacity - 1 &&
|
|
std::none_of(q.state.peers.begin(), q.state.peers.end(), [&](const auto &peer) { return peer.id == p.state.local_id; }))
|
|
return false;
|
|
if (!q.state.hosting && !q.allowed.contains(p.state.local_id))
|
|
return false;
|
|
p.link(id);
|
|
q.link(p.state.local_id);
|
|
return true;
|
|
}
|
|
void SteamTransport::allow_peers(std::span<const Member> members) {
|
|
impl_->allowed.clear();
|
|
impl_->allowed.insert(impl_->host);
|
|
for (const auto &m : members)
|
|
if (m.id != impl_->state.local_id)
|
|
impl_->allowed.insert(m.id);
|
|
auto peers = impl_->state.peers;
|
|
for (const auto &p : peers)
|
|
if (!impl_->allowed.contains(p.id))
|
|
disconnect(p.id, "Removed");
|
|
}
|
|
void SteamTransport::disconnect(std::uint64_t id, const char *reason) {
|
|
impl_->state.detail = reason;
|
|
if (auto it = Impl::bus.find(id); it != Impl::bus.end())
|
|
it->second->state.detail = reason;
|
|
std::erase_if(impl_->state.peers, [&](const auto &p) { return p.id == id; });
|
|
if (auto it = Impl::bus.find(id); it != Impl::bus.end())
|
|
std::erase_if(it->second->state.peers, [&](const auto &p) { return p.id == impl_->state.local_id; });
|
|
}
|
|
void SteamTransport::stop() {
|
|
auto peers = impl_->state.peers;
|
|
for (const auto &p : peers)
|
|
disconnect(p.id, "");
|
|
impl_->allowed.clear();
|
|
impl_->host = 0;
|
|
impl_->state.hosting = false;
|
|
impl_->inbox.clear();
|
|
}
|
|
void SteamTransport::poll() {
|
|
impl_->state.telemetry = true;
|
|
impl_->state.queue_us = SimulatedNetwork::queues[impl_->state.local_id];
|
|
}
|
|
bool SteamTransport::send(std::uint64_t id, std::span<const std::uint8_t> bytes, bool reliable, bool, TrafficLane lane) {
|
|
auto &p = *impl_;
|
|
if (std::none_of(p.state.peers.begin(), p.state.peers.end(), [&](const auto &v) { return v.id == id; }))
|
|
return false;
|
|
++p.state.sent;
|
|
p.state.sent_bytes += bytes.size();
|
|
const auto decoded = decode_wire(bytes);
|
|
if (SimulatedNetwork::audit_lanes) {
|
|
auto kind = decoded ? std::optional(decoded->kind) : std::nullopt;
|
|
if (bytes.size() > 4 && std::equal(bytes.begin(), bytes.begin() + 4, "RMB1")) {
|
|
if (const auto baseline = decode_wire(bytes.subspan(4))) kind = baseline->kind;
|
|
} else if (bytes.size() >= 30 && bytes[0] == 'R' && bytes[1] == 'M' && (bytes[2] == 'D' || bytes[2] == 'S'))
|
|
kind = static_cast<PacketKind>(bytes[20] | (unsigned{bytes[21]} << 8));
|
|
if (!kind || lane != traffic_lane(*kind)) throw std::runtime_error("Stream/reference sent on the wrong Steam lane");
|
|
++SimulatedNetwork::lane_sends[static_cast<std::size_t>(lane)];
|
|
}
|
|
if (SimulatedNetwork::lose_reports && decoded && decoded->kind == PacketKind::routes)
|
|
return true;
|
|
if (!reliable && SimulatedNetwork::lose_unreliable && ++p.unreliable % 5 == 0)
|
|
return true;
|
|
Impl::bus.at(id)->inbox.push_back({{p.state.local_id, {bytes.begin(), bytes.end()}, SimulatedNetwork::clock}, lane});
|
|
return true;
|
|
}
|
|
void SteamTransport::send_batch(std::span<TransportSend> messages) {
|
|
for (auto &message : messages)
|
|
message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane);
|
|
}
|
|
std::vector<TransportMessage> SteamTransport::receive() {
|
|
auto queued = std::exchange(impl_->inbox, {});
|
|
if (SimulatedNetwork::cosmetics_first)
|
|
std::stable_sort(queued.begin(), queued.end(), [](const auto &a, const auto &b) {
|
|
return a.lane == TrafficLane::cosmetics && b.lane != TrafficLane::cosmetics;
|
|
});
|
|
std::vector<TransportMessage> messages;
|
|
for (auto &item : queued) {
|
|
if (SimulatedNetwork::hold_control_to == impl_->state.local_id && item.lane == TrafficLane::control)
|
|
impl_->inbox.push_back(std::move(item));
|
|
else messages.push_back(std::move(item.message));
|
|
}
|
|
return messages;
|
|
}
|
|
const TransportStatus &SteamTransport::status() const { return impl_->state; }
|
|
std::string SteamTransport::name(std::uint64_t id) { return "Player " + std::to_string(id); }
|
|
bool SteamTransport::socket_test() { return true; }
|
|
std::vector<std::string> SteamTransport::take_direct_notes() { return {}; }
|
|
|
|
// No native/game/UI calls in this harness.
|
|
NativeFrame tick_frame;
|
|
unsigned capture_delay_ms{};
|
|
bool captured_full_pose{};
|
|
float shown_root{};
|
|
std::uint64_t shown_at{};
|
|
NativeFrame capture_local(std::uintptr_t, std::uintptr_t) {
|
|
if (capture_delay_ms) Sleep(capture_delay_ms);
|
|
return tick_frame;
|
|
}
|
|
NativeFrame capture_local(std::uintptr_t base, std::uintptr_t client, bool full_pose) {
|
|
captured_full_pose = full_pose;
|
|
return capture_local(base, client);
|
|
}
|
|
bool show_remote(std::uintptr_t, std::uintptr_t, const NativeFrame &, const Pose &pose, std::string &) {
|
|
shown_root = pose.root.position[0];
|
|
shown_at = now_us();
|
|
return true;
|
|
}
|
|
void remove_remote(std::uintptr_t) noexcept {}
|
|
void update_remote_cosmetics(std::uintptr_t, const NativeFrame &, const Appearance &, std::string &) {}
|
|
std::uint64_t remote_pose_updates() noexcept { return 0; }
|
|
std::uint64_t remote_board_pose_updates() noexcept { return 0; }
|
|
NativeAnimationStats remote_animation_stats() noexcept { return {}; }
|
|
std::uintptr_t remote_skater_entity() noexcept { return 0; }
|
|
std::uintptr_t remote_board_entity() noexcept { return 0; }
|
|
std::uint64_t remote_skater_generation() noexcept { return 0; }
|
|
void note_remote_distance(float) noexcept {}
|
|
void update_native_party(std::uintptr_t, const PartyRoster &, unsigned, bool) noexcept {}
|
|
void set_native_party_changes(bool) noexcept {}
|
|
bool post_native_party_invite(std::uint64_t) noexcept { return false; }
|
|
void set_native_nametags_enabled(bool) noexcept {}
|
|
void set_native_compass_enabled(bool) noexcept {}
|
|
void prepare_native_indicators(std::uintptr_t) noexcept {}
|
|
void prepare_player_ui(std::uintptr_t) noexcept {}
|
|
void prepare_remote_audio(std::uintptr_t) noexcept {}
|
|
void prepare_effects(std::uintptr_t) noexcept {}
|
|
std::vector<Impact> drain_impacts() { return {}; }
|
|
void play_impact(const Impact &) noexcept {}
|
|
void note_remote_outfit(std::uint64_t) noexcept {}
|
|
void tick_remote_effects(std::uint64_t) noexcept {}
|
|
void reset_effects() noexcept {}
|
|
bool install_entity_hooks(std::uintptr_t, std::string &) noexcept { return true; }
|
|
void publish_custom_nametags(std::uintptr_t, std::vector<NametagPlayer>, std::optional<std::array<float, 3>>, bool, bool, float, float, bool) noexcept {}
|
|
void set_custom_nametags_enabled(bool) noexcept {}
|
|
GameUiState sample_game_ui_state(std::uintptr_t) noexcept { return {}; }
|
|
void note_local_skater(const Transform &) noexcept {}
|
|
void initialize_native_throwdowns(std::uintptr_t) noexcept {}
|
|
// The relay itself needs the game; routing is checked through each node's inbox.
|
|
std::vector<std::vector<std::uint8_t>> tick_throwdown_relay(std::uintptr_t, const ThrowdownRelayInput &) { return {}; }
|
|
std::vector<std::string> take_throwdown_relay_notices() { return {}; }
|
|
void receive_throwdown_relay(std::uint64_t, std::span<const std::uint8_t>) {}
|
|
bool throwdown_relay_hides(std::uint64_t) noexcept { return false; }
|
|
std::optional<std::array<float, 3>> throwdown_relay_celebration_offset(std::uint64_t, const std::array<float, 3> &) noexcept { return std::nullopt; }
|
|
bool throwdown_relay_waits_offboard() noexcept { return false; }
|
|
std::shared_ptr<const SteamSocialSnapshot> steam_social_snapshot() {
|
|
static const auto snapshot = std::make_shared<SteamSocialSnapshot>();
|
|
return snapshot;
|
|
}
|
|
std::optional<Appearance> capture_cosmetics(std::uintptr_t, const NativeFrame &, std::string &) { return {}; }
|
|
void prepare_audio_capture(std::uintptr_t, const NativeFrame &) noexcept {}
|
|
std::vector<AudioSample> drain_audio_capture(std::uint64_t) { return {}; }
|
|
void update_remote_audio(std::uintptr_t, const NativeFrame &, const Pose &, const AudioState &) noexcept {}
|
|
void stop_remote_audio() noexcept {}
|
|
void reset_audio() noexcept {}
|
|
std::string native_audio_status() { return {}; }
|
|
std::uint64_t captured_audio_frames() noexcept { return 0; }
|
|
std::uint64_t played_audio_frames() noexcept { return 0; }
|
|
void update_player_ui(std::uintptr_t, const Pose *, std::string) noexcept {}
|
|
void update_party_position(const Pose *) noexcept {}
|
|
std::string player_ui_status() { return {}; }
|
|
std::uint64_t player_map_updates() noexcept { return 0; }
|
|
std::unique_ptr<LobbyApi> make_steam_lobby_api() { return {}; }
|
|
|
|
namespace {
|
|
void check(bool value, const char *why) {
|
|
if (!value)
|
|
throw std::runtime_error(why);
|
|
}
|
|
// Exercise the public UI command queue and Steam lobby completion through tick(),
|
|
// rather than starting a simulated Session after admission has already begun.
|
|
struct JoinLobby final : LobbyApi {
|
|
std::uint64_t host_id{};
|
|
bool completed{};
|
|
std::map<std::string, std::string> metadata;
|
|
void open() override { Sleep(2); }
|
|
std::uint64_t create(unsigned) override { return 0; }
|
|
std::uint64_t search() override { return 1; }
|
|
std::uint64_t join(std::uint64_t) override { return 2; }
|
|
std::optional<LobbyResult> poll(std::uint64_t, LobbyCall kind) override {
|
|
if (kind == LobbyCall::list) return LobbyResult{true, 0, 1, 1};
|
|
if (kind == LobbyCall::join && completed) return LobbyResult{true, 9001, 0, 1};
|
|
return {};
|
|
}
|
|
void leave(std::uint64_t) override {}
|
|
std::uint64_t at(int) override { return 9001; }
|
|
std::uint64_t owner(std::uint64_t) override { return host_id; }
|
|
std::string data(std::uint64_t, const char *key) override { return metadata[key]; }
|
|
bool data(std::uint64_t, const char *, const std::string &) override { return true; }
|
|
bool joinable(std::uint64_t, bool) override { return true; }
|
|
bool visibility(std::uint64_t, bool) override { return true; }
|
|
std::string name() override { return "Host"; }
|
|
};
|
|
void join_tick_checks() {
|
|
auto &guest = session();
|
|
SimulatedNetwork::slow_join = true;
|
|
tick_frame.ready = true;
|
|
tick_frame.pose.skater.resize(1);
|
|
const std::string map = "Levels/Root/Root|Levels/Beach/Beach";
|
|
auto host_storage = std::make_unique<Session>();
|
|
auto &host = *host_storage;
|
|
host.mode = Mode::host;
|
|
host.secret = 73;
|
|
host.epoch = 100;
|
|
host.map = map_hash(map);
|
|
host.map_name = map;
|
|
host.started_map = true;
|
|
host.transport.host(8);
|
|
host.host_id = host.transport.status().local_id;
|
|
const auto code = format_invite({host.host_id, host.secret});
|
|
for (unsigned route = 0; route < 3; ++route) for (bool protected_lobby : {false, true}) {
|
|
const bool browser = route != 0, friend_join = route == 2;
|
|
if (friend_join && protected_lobby) continue; // Password rejection is covered by the lobby adapter tests.
|
|
stop(guest, "Next join test");
|
|
check(!simulated_object_guest, "Leaving retained the guest object layout");
|
|
check(!guest.joined_public_lobby, "Stopping must clear the public friend-join target");
|
|
const auto password = protected_lobby ? "test password" : "";
|
|
host.password = password_key(password, host.secret);
|
|
networking(host, tick_frame, now_us());
|
|
JoinLobby *api{};
|
|
if (browser) {
|
|
auto backend = std::make_unique<JoinLobby>();
|
|
api = backend.get();
|
|
api->host_id = host.host_id;
|
|
api->metadata = {{"rs_kind", "reskate-co-skate"},
|
|
{"rs_protocol", std::to_string(protocol_version)},
|
|
{"rs_build", std::string(supported_build::game_sha256)},
|
|
{"rs_name", "Host"}, {"rs_map", map}, {"rs_code", code},
|
|
{"rs_players", "1"}, {"rs_capacity", "8"}, {"rs_open", "1"},
|
|
{"rs_password", protected_lobby ? "1" : "0"}};
|
|
guest.lobbies = SteamLobbies(std::move(backend));
|
|
if (!friend_join) {
|
|
guest.lobbies.refresh(1000000);
|
|
guest.lobbies.tick(1100000);
|
|
}
|
|
}
|
|
check(queue_command(friend_join ? "join-friend-lobby" : browser ? "join-lobby" : "join", browser ? "9001" : code, password),
|
|
"UI join request was not queued");
|
|
tick(0, 0, true, map, nullptr);
|
|
if (browser) {
|
|
check(guest.lobbies.status().joining, "Fresh browser request timed out in its starting tick");
|
|
api->completed = true;
|
|
Sleep(110); // The real lobby adapter polls at 100 ms intervals.
|
|
tick(0, 0, true, map, nullptr);
|
|
}
|
|
check(guest.mode == Mode::join && guest.awaiting_map,
|
|
"Fresh join timed out in its starting tick");
|
|
check(simulated_object_guest, "Joining did not isolate the guest's personal object layout");
|
|
for (unsigned frame = 0; frame < 50 && !guest.peers[0].handshaken; ++frame) {
|
|
networking(host, tick_frame, now_us());
|
|
tick(0, 0, true, map, nullptr);
|
|
Sleep(1);
|
|
}
|
|
check(guest.mode == Mode::join && player_count(guest) == 2 && !guest.awaiting_map,
|
|
"Queued join did not complete map/password/admission handshake");
|
|
check(guest.joined_public_lobby == (browser ? 9001ULL : 0ULL), "Only verified lobby joins retain a public presence target");
|
|
// Party requests (menus and the game's own party buttons) go through the same queue.
|
|
check(queue_command("party", "status", {}), "A party request was not queued");
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.requests.empty(), "A queued party request was not run");
|
|
// So do the host's switches in both menus: each has to be let into the queue. The
|
|
// physics tuning one was left out, and its switch did nothing in either menu.
|
|
for (const char *setting : {"object-placement", "noclip-allow", "nobail-allow", "boosts-allow", "tuning-enforce",
|
|
"world-layer-sync", "clear-objects"}) {
|
|
const bool queued = queue_command(setting, "toggle", {});
|
|
if (!queued) std::cerr << "Refused: " << setting << "\n";
|
|
check(queued, "A menu's host setting was refused before it reached the session");
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.requests.empty(), "A queued host setting was not run");
|
|
}
|
|
if (friend_join) {
|
|
check(queue_command("join-friend-lobby", "9002", {}), "Steam friend join request should enter the normal queue");
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.mode == Mode::join && guest.host_id == host.host_id && guest.joined_public_lobby == 9001,
|
|
"A Steam join click must not replace an active session");
|
|
// Picking the server they are on in the browser changes nothing either; picking
|
|
// another one leaves this session for it.
|
|
check(queue_command("join-lobby", "9001", {}), "A browser pick should enter the normal queue");
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.mode == Mode::join && guest.host_id == host.host_id && guest.joined_public_lobby == 9001,
|
|
"Picking the current server replaced the session");
|
|
check(queue_command("join-lobby", "9002", {}), "A browser hop should enter the normal queue");
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.joined_public_lobby != 9001 && guest.status != "Leave your current session before joining another lobby.",
|
|
"A guest could not hop to another server from the browser");
|
|
}
|
|
}
|
|
stop(guest, "Timeout checks");
|
|
networking(host, tick_frame, now_us());
|
|
check(queue_command("join", code, {}), "Timeout join was not queued");
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.mode == Mode::join, "Fresh timeout-check join failed");
|
|
guest.join_started = now_us() - 180000001;
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.mode == Mode::off && guest.status.find("timed out") != std::string::npos,
|
|
"Real three-minute join timeout was disabled");
|
|
check(queue_command("join", code, {}), "Stale join was not queued");
|
|
guest.requests.back()->queued = now_us() - 10000001;
|
|
tick(0, 0, true, map, nullptr);
|
|
check(guest.mode == Mode::off, "Expired UI command was executed");
|
|
stop(guest, "Join tick checks complete");
|
|
guest.lobbies = SteamLobbies(nullptr);
|
|
tick_frame = {};
|
|
SimulatedNetwork::slow_join = false;
|
|
std::cout << "Join tick checks: queued code/browser joins, password admission, pending Steam calls, "
|
|
"real timeout and stale commands passed.\n";
|
|
}
|
|
void client_tick_checks() {
|
|
auto &s = session();
|
|
const std::string map = "Levels/Root/Root|Levels/Beach/Beach";
|
|
tick_frame = {};
|
|
tick_frame.ready = true;
|
|
tick_frame.pose.skater.resize(395);
|
|
tick_frame.pose.board.resize(17);
|
|
command("echo", {}, {});
|
|
tick(0, 0, true, map, nullptr);
|
|
auto &peer = s.peers[0];
|
|
peer.poses.clear();
|
|
const auto base = now_us();
|
|
auto pose = packet(s, PacketKind::pose, base - 200000);
|
|
pose.pose = tick_frame.pose;
|
|
check(peer.poses.push(pose, base - 200000), "Client timing reference rejected");
|
|
++pose.sequence; pose.time_us = base; pose.pose.root.position[0] = 2;
|
|
check(peer.poses.push(pose, base), "Client timing latest pose rejected");
|
|
auto outfit = packet(s, PacketKind::cosmetics, base);
|
|
outfit.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit", {}}}},
|
|
{board_recipe_key, 1, {}, {{2, "Deck", {}}}}};
|
|
check(peer.appearance.push(outfit), "Client timing appearance rejected");
|
|
s.next_send = base + 1000000;
|
|
capture_delay_ms = 40;
|
|
shown_at = 0;
|
|
tick(0, 0, true, map, nullptr);
|
|
capture_delay_ms = 0;
|
|
check(!captured_full_pose, "Client captured a complete skeleton before the send deadline");
|
|
const auto expected = 1.f + static_cast<float>(shown_at - base) / 100000.f;
|
|
check(shown_at >= base && shown_root > 1.35f && std::abs(shown_root - std::min(expected, 2.f)) < .05f,
|
|
"Client capture work left playback on the old frame timestamp");
|
|
const auto timing = s.client_timing.snapshot(now_us());
|
|
check(timing.peak_ms[ClientTiming::capture] >= 35 && timing.work_max_ms >= 35,
|
|
"Actual client capture stall was absent from diagnostics");
|
|
stop(s, "Client timing checks complete");
|
|
tick_frame = {};
|
|
std::cout << "Client tick: injected capture stall measured; playback uses fresh wall time while Local Echo remains buffered.\n";
|
|
}
|
|
struct Simulation {
|
|
std::vector<std::unique_ptr<Session>> nodes;
|
|
std::uint64_t now = 10000000;
|
|
NativeFrame local;
|
|
std::vector<float> positions;
|
|
unsigned capacity, tps;
|
|
std::size_t held = static_cast<std::size_t>(-1); // a node that is not run: held up, reading nothing
|
|
bool stamp{}; // messages carry when they arrived
|
|
explicit Simulation(unsigned limit = max_players, unsigned rate = 20) : capacity(limit), tps(rate) {
|
|
local.ready = true;
|
|
local.pose.skater.resize(395);
|
|
local.pose.board.resize(17);
|
|
}
|
|
Session &add(bool correct_password = true) {
|
|
auto s = std::make_unique<Session>();
|
|
s->capacity = capacity;
|
|
s->secret = 73;
|
|
s->epoch = nodes.size() + 100;
|
|
s->map = 42;
|
|
s->started_map = true;
|
|
s->password = password_key(correct_password ? "test room" : "wrong password", s->secret);
|
|
if (nodes.empty()) {
|
|
s->tps = tps;
|
|
s->mode = Mode::host;
|
|
s->transport.host(capacity);
|
|
s->host_id = s->transport.status().local_id;
|
|
} else {
|
|
s->mode = Mode::join;
|
|
s->host_id = nodes.front()->host_id;
|
|
s->transport.join(s->host_id);
|
|
}
|
|
auto cosmetics = packet(*s, PacketKind::cosmetics, now);
|
|
cosmetics.appearance = {
|
|
{skater_recipe_key, 2, {}, {{1, "Outfit" + std::to_string(nodes.size()), {}}}},
|
|
{board_recipe_key, 1, {}, {{2, "Deck", {}}}}};
|
|
s->cosmetic_packet = encode_wire(cosmetics);
|
|
nodes.push_back(std::move(s));
|
|
return *nodes.back();
|
|
}
|
|
void run(unsigned frames) {
|
|
for (unsigned i = 0; i < frames; ++i) {
|
|
now += network_tick_us;
|
|
SimulatedNetwork::clock = stamp ? now : 0;
|
|
for (std::size_t n = 0; n < nodes.size(); ++n) {
|
|
auto &s = *nodes[n];
|
|
if (s.mode == Mode::off || n == held)
|
|
continue;
|
|
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);
|
|
send_local(s, local, now);
|
|
auto sound = packet(s, PacketKind::audio, now);
|
|
sound.audio = {AudioSample{}};
|
|
sound.audio[0].state.values[1] = static_cast<float>(n) + .5f;
|
|
broadcast(s, sound, true, true, now);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
void fresh(unsigned count) {
|
|
for (unsigned i = 0; i < count; ++i)
|
|
for (unsigned j = 0; j < count; ++j)
|
|
if (i != j) {
|
|
auto *p = find_peer(*nodes[i], nodes[j]->transport.status().local_id);
|
|
if (!p || !p->handshaken) {
|
|
std::cerr << "Missing source " << j << " at node " << i << " count=" << count
|
|
<< " time=" << now << " status=" << nodes[i]->status << "\n";
|
|
for (std::size_t k = 0; k < nodes.size(); ++k)
|
|
std::cerr << k << ": " << nodes[k]->status << "\n";
|
|
throw std::runtime_error("Admitted player missing from roster");
|
|
}
|
|
check(p->appearance.value().has_value(),
|
|
"Player cosmetic recipe missing after route transition");
|
|
const auto sound = p->audio.sample(now);
|
|
check(sound && sound->values[1] == static_cast<float>(j) + .5f,
|
|
"Player sound missing or attributed to wrong source");
|
|
const auto pose = p->poses.sample(now);
|
|
const auto lag = pose ? local.pose.root.position[0] - pose->root.position[0] : 999.f;
|
|
const auto bound = .25f + 3.f * static_cast<float>(p->received_pose_interval) / 1000000.f;
|
|
if (lag >= bound) std::cerr << "Lag: receiver=" << i << " sender=" << j << " seconds=" << lag
|
|
<< " cadence=" << p->received_pose_interval << " arrival_age=" << (now - p->pose_arrival) << "\n";
|
|
check(pose && lag < bound, "Remote pose fell behind sender time");
|
|
}
|
|
}
|
|
};
|
|
// Linked throwdowns travel through the host like chat: each message reaches every
|
|
// other player exactly once, never its sender, and never another world.
|
|
void throwdown_routing_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(60); sim.fresh(3);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2];
|
|
const auto received = [](const Session &s, std::uint64_t from, const std::vector<std::uint8_t> &message) {
|
|
return std::count_if(s.throwdown_inbox.begin(), s.throwdown_inbox.end(),
|
|
[&](const auto &entry) { return entry.first == from && entry.second == message; });
|
|
};
|
|
const auto clear = [&] { for (auto &node : sim.nodes) node->throwdown_inbox.clear(); };
|
|
clear();
|
|
const std::vector<std::uint8_t> from_guest{3, 1, 2, 3}, from_host{5, 9, 9};
|
|
send_throwdown(first, from_guest);
|
|
sim.run(10);
|
|
const auto guest_id = first.transport.status().local_id;
|
|
check(received(host, guest_id, from_guest) == 1 && received(second, guest_id, from_guest) == 1 &&
|
|
first.throwdown_inbox.empty(),
|
|
"A guest's throwdown message did not reach every other player exactly once");
|
|
clear();
|
|
send_throwdown(host, from_host);
|
|
sim.run(10);
|
|
const auto host_id = host.transport.status().local_id;
|
|
check(received(first, host_id, from_host) == 1 && received(second, host_id, from_host) == 1 && host.throwdown_inbox.empty(),
|
|
"The host's throwdown message did not reach every guest exactly once");
|
|
clear();
|
|
auto stale = packet(first, PacketKind::throwdown, sim.now);
|
|
stale.throwdown = from_guest;
|
|
stale.world = first.world + 1;
|
|
check(first.transport.send(first.host_id, encode_wire(stale), true), "Could not queue a wrong-world throwdown fixture");
|
|
sim.run(10);
|
|
check(host.throwdown_inbox.empty() && second.throwdown_inbox.empty(), "A throwdown message from another world was relayed");
|
|
}
|
|
// A lobby's parties are the ones its players form, kept by the host the way a dedicated
|
|
// server keeps them: nobody is in a party for being in the lobby.
|
|
void party_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 4; ++i) sim.add();
|
|
sim.run(60); sim.fresh(4);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2], &third = *sim.nodes[3];
|
|
const auto id = [](const Session &s) { return s.transport.status().local_id; };
|
|
for (const auto &node : sim.nodes)
|
|
check(!node->local_party && node->party_invites.empty(), "A lobby put a player in a party nobody formed");
|
|
|
|
check(send_party_request(first, PartyAction::invite, id(second)).empty(), "A guest's party invite was not sent");
|
|
sim.run(20);
|
|
check(second.party_invites.size() == 1 && second.party_invites.front().from == id(first),
|
|
"The host did not pass a guest's invite on to the invited guest");
|
|
check(!first.local_party && !second.local_party, "An unanswered invite made a party");
|
|
check(send_party_request(second, PartyAction::accept, id(first)).empty(), "Accepting an invite was not sent");
|
|
sim.run(40);
|
|
check(first.local_party && first.local_party == second.local_party && first.local_party_leader && !second.local_party_leader,
|
|
"An accepted invite did not put both guests in one party led by the inviter");
|
|
check(party_member(first, id(second)) && party_member(second, id(first)), "Party members do not see each other as members");
|
|
check(!host.local_party && !third.local_party && !party_member(third, id(first)) && party_of(third, id(first)) == first.local_party,
|
|
"Players outside a party were put in it, or cannot see who is in it");
|
|
|
|
// The host's own requests are answered on the spot, and make a second party.
|
|
check(send_party_request(host, PartyAction::invite, id(third)).empty(), "The host's own party invite was refused");
|
|
sim.run(20);
|
|
check(third.party_invites.size() == 1 && third.party_invites.front().from == id(host), "The host's invite did not reach the guest");
|
|
check(send_party_request(third, PartyAction::accept, id(host)).empty(), "Accepting the host's invite was not sent");
|
|
sim.run(40);
|
|
check(host.local_party && host.local_party == third.local_party && host.local_party_leader && host.local_party != first.local_party,
|
|
"The host and its guest are not in a party of their own");
|
|
check(party_of(first, id(third)) == host.local_party && !party_member(first, id(third)), "A guest does not see the other party");
|
|
|
|
// Party chat reaches the party and nobody else.
|
|
const auto heard = [](const Session &s, std::string_view words) {
|
|
return std::any_of(s.chat.begin(), s.chat.end(), [&](const auto &line) { return line.text.find(words) != std::string::npos; });
|
|
};
|
|
check(send_party_chat(first, "meet at the bowl").empty(), "A guest's party chat was refused");
|
|
sim.run(20);
|
|
check(heard(first, "meet at the bowl") && heard(second, "meet at the bowl"), "Party chat did not reach the party");
|
|
check(!heard(host, "meet at the bowl") && !heard(third, "meet at the bowl"), "Party chat reached players outside the party");
|
|
check(send_party_chat(host, "host party only").empty(), "The host's party chat was refused");
|
|
sim.run(20);
|
|
check(heard(third, "host party only") && !heard(first, "host party only") && !heard(second, "host party only"),
|
|
"The host's party chat did not stay in its party");
|
|
|
|
// Leaving: a party left with one member is no party.
|
|
check(send_party_request(second, PartyAction::leave, 0).empty(), "Leaving a party was not sent");
|
|
sim.run(40);
|
|
check(!first.local_party && !second.local_party, "A party of one was kept");
|
|
check(send_party_chat(first, "anyone").starts_with("You're not in a party"), "Party chat was sent without a party");
|
|
// A guest leaving the lobby is out of their party as well.
|
|
stop(third, "Left"); sim.run(40);
|
|
check(!host.local_party && host.parties.parties().empty(), "A guest who left the lobby stayed in the host's party");
|
|
std::cout << "Lobby parties: nobody by default, invites, two parties, party chat, leaving and departures passed.\n";
|
|
}
|
|
// A player's badge and colour, in chat and on their nametag: who the backend lists them as
|
|
// comes before what they are in the lobby, and a developer before the other lists.
|
|
void role_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 4; ++i) sim.add();
|
|
sim.run(60); sim.fresh(4);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2], &third = *sim.nodes[3];
|
|
const auto id = [](const Session &s) { return s.transport.status().local_id; };
|
|
using Role = std::pair<std::uint32_t, std::string>;
|
|
using L = IdentityList;
|
|
check(player_role(first, id(host), false) == Role{nametag_host, "Host"} &&
|
|
player_role(host, id(host), true) == Role{nametag_host, "Host"} &&
|
|
player_role(host, id(first), false) == Role{nametag_white, {}},
|
|
"Players the backend does not list did not get their lobby roles");
|
|
|
|
simulated_identities = {{id(host), L::homie}, {id(first), L::content_creator},
|
|
{id(second), L::developer}, {id(second), L::content_creator}, {id(second), L::homie},
|
|
{id(third), L::content_creator}, {id(third), L::homie}};
|
|
check(player_role(first, id(host), false) == Role{nametag_homie, "Homie"} &&
|
|
player_role(host, id(host), true) == Role{nametag_homie, "Homie"},
|
|
"A homie who hosts is not shown as a homie");
|
|
check(player_role(host, id(first), false) == Role{nametag_creator, "Content Creator"} &&
|
|
player_role(first, id(first), true) == Role{nametag_creator, "Content Creator"},
|
|
"A content creator is not shown as one");
|
|
check(player_role(host, id(second), false) == Role{nametag_developer, "Dev"},
|
|
"A developer on every list is not shown as a developer");
|
|
check(player_role(host, id(third), false) == Role{nametag_creator, "Content Creator"},
|
|
"A content creator who is also a homie is not shown as a creator");
|
|
// A special tag comes before every lobby role: a Centrix player who hosts is Centrix, not Host.
|
|
simulated_identities.insert({id(host), L::centrix});
|
|
check(player_role(first, id(host), false) == Role{nametag_centrix, "Centrix"} &&
|
|
player_role(host, id(host), true) == Role{nametag_centrix, "Centrix"},
|
|
"A Centrix player who hosts is not shown as Centrix");
|
|
simulated_identities.erase({id(host), L::centrix});
|
|
// A player who has turned their tag off (the Special page) is whatever they are in the lobby,
|
|
// to everyone: their appearance carries the choice through the host. Their items are a
|
|
// separate choice, which leaves the tag alone.
|
|
auto plain = packet(host, PacketKind::cosmetics, sim.now);
|
|
plain.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit0", {}}}}, {board_recipe_key, 1, {}, {{2, "Deck", {}}}}};
|
|
plain.appearance.hide_items = true;
|
|
host.cosmetic_packet = encode_wire(plain);
|
|
broadcast(host, plain, true, false, sim.now);
|
|
sim.run(20);
|
|
const auto told = [&](const Session &s) {
|
|
const auto *peer = find_peer(const_cast<Session &>(s), id(host));
|
|
return peer && !shows_items(*peer) && shows_tag(*peer);
|
|
};
|
|
check(told(first) && told(second) && player_role(first, id(host), false) == Role{nametag_homie, "Homie"},
|
|
"A homie who turned their items off was not seen to, or lost their tag");
|
|
// A later packet, as the host's game would send after the change.
|
|
const auto look = plain.appearance;
|
|
plain = packet(host, PacketKind::cosmetics, sim.now);
|
|
plain.appearance = look;
|
|
plain.appearance.hide_items = false, plain.appearance.hide_tag = true;
|
|
host.cosmetic_packet = encode_wire(plain);
|
|
broadcast(host, plain, true, false, sim.now);
|
|
sim.run(20);
|
|
check(player_role(first, id(host), false) == Role{nametag_host, "Host"} &&
|
|
player_role(second, id(host), false) == Role{nametag_host, "Host"},
|
|
"A homie who turned their tag off still shows as a homie");
|
|
const auto *shown = find_peer(first, id(host));
|
|
check(shown && shows_items(*shown), "Turning their tag off turned a player's items off");
|
|
check(player_role(host, id(first), false) == Role{nametag_creator, "Content Creator"}, "One player's choice hid another's tag");
|
|
show_own_items(false);
|
|
check(player_role(first, id(first), true) == Role{nametag_creator, "Content Creator"}, "Turning their items off hid a player's own tag");
|
|
show_own_items(true);
|
|
show_own_tag(false);
|
|
check(player_role(first, id(first), true) == Role{nametag_white, {}} &&
|
|
player_role(host, id(first), false) == Role{nametag_creator, "Content Creator"},
|
|
"A player's own choice did not hide their tag from themselves, or hid it from others before they were told");
|
|
show_own_tag(true);
|
|
// A chat line carries its sender's role.
|
|
check(send_chat(first, "new video is up").empty(), "A guest's chat was refused");
|
|
sim.run(20);
|
|
const auto said = std::find_if(host.chat.begin(), host.chat.end(), [](const auto &line) { return line.text == "new video is up"; });
|
|
check(said != host.chat.end() && said->color == nametag_creator && said->tag == "Content Creator",
|
|
"A content creator's chat line does not carry their role");
|
|
|
|
// None of it can be claimed. A badge goes to a Steam identity this PC is itself connected to:
|
|
// a guest knows another guest only from the host's roster until Steam connects the two, and
|
|
// a roster alone, which a host fills as it likes, gives nobody a badge.
|
|
const Role developer{nametag_developer, "Dev"};
|
|
auto *seen = find_peer(first, id(second));
|
|
check(seen && seen->direct_ready && player_role(first, id(second), false) == developer &&
|
|
player_role(third, id(second), false) == developer,
|
|
"Guests connected to a developer do not see them as one");
|
|
seen->direct_ready = false;
|
|
check(!steam_vouched(first, *seen) && player_role(first, id(second), false) == Role{nametag_white, {}},
|
|
"A guest took a developer's identity from the host's roster alone");
|
|
seen->direct_ready = true;
|
|
// In chat a guest's badge rests on their own copy of the line, sent straight to the players
|
|
// Steam connects them to: the host passes lines on, and could pass on anything under
|
|
// anyone's name.
|
|
const auto line_of = [](const Session &s, std::string_view text) {
|
|
const auto line = std::find_if(s.chat.begin(), s.chat.end(), [&](const auto &v) { return v.text == text; });
|
|
return line == s.chat.end() ? nullptr : &*line;
|
|
};
|
|
check(send_chat(second, "patch notes are out").empty(), "A developer's chat was refused");
|
|
sim.run(6);
|
|
const auto *real = line_of(third, "patch notes are out"), *at_host = line_of(host, "patch notes are out");
|
|
check(real && real->tag == "Dev" && real->color == nametag_developer && at_host && at_host->tag == "Dev",
|
|
"A developer's own line lost its badge on the way, or waited");
|
|
check(std::count_if(third.chat.begin(), third.chat.end(), [](const auto &v) { return v.text == "patch notes are out"; }) == 1,
|
|
"A line and its sender's own copy of it were both shown");
|
|
// A line the host makes up under a developer's name waits for their copy, then shows as
|
|
// any other player's would.
|
|
auto lie = packet(host, PacketKind::chat, sim.now);
|
|
lie.source = id(second), lie.epoch = second.epoch;
|
|
lie.text = "send me your password";
|
|
check(send_packet(host, id(third), lie, true, false), "The made-up line could not be sent");
|
|
sim.run(4);
|
|
check(!line_of(third, "send me your password"), "A line without its sender's copy did not wait for it");
|
|
sim.run(20);
|
|
const auto *made_up = line_of(third, "send me your password");
|
|
check(made_up && made_up->tag.empty() && made_up->color == nametag_white, "A line the host made up carries a developer's badge");
|
|
// So does one from a build that sends no copy; the host, who has it from the developer
|
|
// themselves, shows the badge.
|
|
auto old = packet(second, PacketKind::chat, sim.now);
|
|
old.text = "from an older build";
|
|
check(send_packet(second, id(host), old, true, false), "The older build's line could not be sent");
|
|
sim.run(30);
|
|
const auto *bare = line_of(third, "from an older build"), *first_hand = line_of(host, "from an older build");
|
|
check(bare && bare->tag.empty() && first_hand && first_hand->tag == "Dev",
|
|
"A line with no copy from its sender shows a badge to a guest, or lost it at the host");
|
|
// The other lists' badges travel the same way, guest to guest.
|
|
check(send_chat(third, "clip is on my channel").empty(), "A content creator's chat was refused");
|
|
sim.run(6);
|
|
const auto *clip = line_of(first, "clip is on my channel");
|
|
check(clip && clip->color == nametag_creator && clip->tag == "Content Creator", "A content creator's own line lost its badge");
|
|
// A player on no list stays plain whatever their own packets ask for: the styles only shape
|
|
// what a list already gives.
|
|
simulated_identities.erase({id(first), L::content_creator});
|
|
auto wish = packet(first, PacketKind::cosmetics, sim.now);
|
|
wish.appearance = look;
|
|
wish.appearance.marks.fill({MarkMode::gradient, {255, 0, 0}, {0, 0, 255}, 2});
|
|
first.cosmetic_packet = encode_wire(wish);
|
|
broadcast(first, wish, true, false, sim.now);
|
|
sim.run(20);
|
|
const auto *wisher = find_peer(host, id(first));
|
|
check(wisher && wisher->appearance.value() && wisher->appearance.value()->marks[0].mode == MarkMode::gradient &&
|
|
!identity_mark(id(first)) && player_role(host, id(first), false) == Role{nametag_white, {}} &&
|
|
player_role(third, id(first), false) == Role{nametag_white, {}},
|
|
"A player on no list got a badge by sending styles");
|
|
// And their chat is as it always was: shown as it arrives, with no copy sent or waited for.
|
|
check(send_chat(first, "anyone at the plaza").empty(), "A guest's chat was refused");
|
|
sim.run(3);
|
|
const auto *ordinary = line_of(third, "anyone at the plaza");
|
|
const auto *said_to = find_peer(third, id(first));
|
|
check(ordinary && ordinary->tag.empty() && ordinary->color == nametag_white && said_to && said_to->chat_proofs.empty() &&
|
|
said_to->chat_waiting.empty(),
|
|
"An ordinary player's line was held up, or a copy of it was kept");
|
|
// Nor can anyone name someone else as the sender. What does not come over that player's own
|
|
// Steam connection is refused, and whoever sent it is out of the session.
|
|
const auto heard = [](const Session &s) {
|
|
return std::any_of(s.chat.begin(), s.chat.end(), [](const auto &line) { return line.text == "free decks at my link"; });
|
|
};
|
|
auto forged = packet(first, PacketKind::chat, sim.now);
|
|
forged.source = id(second), forged.epoch = second.epoch;
|
|
forged.text = "free decks at my link";
|
|
check(send_packet(first, id(host), forged, true, false) && send_packet(first, id(third), forged, true, false),
|
|
"The forged chat line could not be sent");
|
|
sim.run(20);
|
|
check(!heard(host) && !heard(second) && !heard(third), "A guest spoke as a developer");
|
|
check(!find_peer(host, id(first)) && !find_peer(third, id(first)), "A guest who forged a sender stayed in the session");
|
|
// The same for how a developer looks: a guest cannot hide or restyle their tag and items.
|
|
auto costume = packet(third, PacketKind::cosmetics, sim.now);
|
|
costume.source = id(second), costume.epoch = second.epoch;
|
|
costume.sequence += 1000;
|
|
costume.appearance = look;
|
|
costume.appearance.hide_tag = costume.appearance.hide_items = true;
|
|
check(send_packet(third, id(host), costume, true, false), "The forged outfit could not be sent");
|
|
sim.run(20);
|
|
const auto *theirs = find_peer(host, id(second));
|
|
check(player_role(host, id(second), false) == developer && theirs && shows_tag(*theirs) && shows_items(*theirs) &&
|
|
!find_peer(host, id(third)),
|
|
"A guest changed how a developer shows, or stayed in the session after trying");
|
|
simulated_identities.clear();
|
|
std::cout << "Roles: lobby roles, homie, content creator, developer first, chat lines and claimed identities passed.\n";
|
|
}
|
|
// The backend's bans hold in every session, and reach one that is running: a banned guest is
|
|
// out and cannot come back, the others stay, and nobody stays with a banned host.
|
|
void global_ban_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(60); sim.fresh(3);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2];
|
|
const auto id = [](const Session &s) { return s.transport.status().local_id; };
|
|
const auto first_id = id(first), host_id = id(host);
|
|
|
|
simulated_identities = {{first_id, IdentityList::banned}};
|
|
sim.run(40);
|
|
check(first.mode == Mode::off && first.status == banned_notice, "A banned guest stayed in the session");
|
|
check(!find_peer(host, first_id) && !find_peer(second, first_id), "The session kept a banned guest");
|
|
check(second.mode == Mode::join && find_peer(host, id(second)), "Banning one guest took another out");
|
|
// A banned player whose game does not stop itself is turned away by the host all the same.
|
|
first.mode = Mode::join;
|
|
first.host_id = host_id;
|
|
first.transport.join(host_id);
|
|
host.transport.poll();
|
|
networking(host, sim.local, sim.now);
|
|
const auto &links = host.transport.status().peers;
|
|
check(std::none_of(links.begin(), links.end(), [&](const auto &link) { return link.id == first_id; }) &&
|
|
!find_peer(host, first_id),
|
|
"The host let a banned player back in");
|
|
stop(first, "Left");
|
|
|
|
simulated_identities = {{host_id, IdentityList::banned}};
|
|
sim.run(40);
|
|
check(host.mode == Mode::off && host.status == banned_notice, "A banned host kept hosting");
|
|
check(second.mode == Mode::off, "A guest stayed with a banned host");
|
|
simulated_identities.clear();
|
|
std::cout << "Global bans: a banned guest, a guest who comes back, the other guests and a banned host passed.\n";
|
|
}
|
|
// A host that is kept from reading for a few seconds (a server whose console held it up) then
|
|
// reads everything its guests sent meanwhile in one go. That is not a flood: nobody is dropped
|
|
// for it, while a real flood still is.
|
|
void stall_checks() {
|
|
{
|
|
// 16 s of a player's ordinary traffic read at once, counted by when it arrived.
|
|
ReceiveBudget backlog;
|
|
bool kept = true;
|
|
for (std::uint64_t i = 0; i < 16 * 90; ++i) kept = kept && backlog.accept(5000000 + i * (1000000 / 90), 300);
|
|
check(kept, "A backlog read after a stall was taken for a flood");
|
|
ReceiveBudget flood;
|
|
bool refused = false;
|
|
for (std::uint64_t i = 0; i < 2000; ++i) refused = refused || !flood.accept(5000000 + i * 100, 300);
|
|
check(refused, "A flood was let through the packet limit");
|
|
// Lanes are read one after another, so arrival times step back and forth.
|
|
ReceiveBudget lanes;
|
|
refused = false;
|
|
for (std::uint64_t i = 0; i < 2000; ++i) refused = refused || !lanes.accept(i % 2 ? 9000000 : 9500000, 300);
|
|
check(refused, "Packets read out of arrival order started the count again");
|
|
}
|
|
Simulation sim(max_players, 120);
|
|
sim.stamp = true;
|
|
for (unsigned i = 0; i < 4; ++i) sim.add();
|
|
sim.run(240);
|
|
auto &host = *sim.nodes[0];
|
|
const auto connected = [&] {
|
|
unsigned count{};
|
|
for (std::size_t n = 1; n < sim.nodes.size(); ++n) {
|
|
const auto *peer = find_peer(host, sim.nodes[n]->transport.status().local_id);
|
|
count += peer && peer->handshaken && sim.nodes[n]->mode == Mode::join;
|
|
}
|
|
return count;
|
|
};
|
|
check(connected() == 3, "The stall fixture did not connect its guests");
|
|
// Nine seconds unread: under the ten after which a silent player is given up on, and more
|
|
// than a second's allowance from every guest.
|
|
const auto sent = sim.nodes[1]->transport.status().sent;
|
|
sim.held = 0;
|
|
sim.run(static_cast<unsigned>(9000000 / network_tick_us));
|
|
sim.held = static_cast<std::size_t>(-1);
|
|
check(sim.nodes[1]->transport.status().sent - sent > 2ULL * multiplayer_tick_rates.back() + 80 + 32,
|
|
"The stall fixture did not queue more than the packet limit");
|
|
sim.run(240);
|
|
if (host.mode != Mode::host || connected() != 3)
|
|
for (std::size_t n = 0; n < sim.nodes.size(); ++n)
|
|
std::cerr << " node " << n << " mode " << static_cast<int>(sim.nodes[n]->mode) << ": " << sim.nodes[n]->status << "\n";
|
|
check(host.mode == Mode::host && connected() == 3, "Guests were dropped after their host was held up for a few seconds");
|
|
SimulatedNetwork::clock = 0;
|
|
std::cout << "Stalls: a held-up host keeps its guests; floods and out-of-order arrival are still limited.\n";
|
|
}
|
|
// What a host changes outside its physics tuning (the trainer's class values and trick
|
|
// multipliers) reaches its guests while it sets everyone's physics: when it changes, to a
|
|
// player who joins later, and from nobody but the host.
|
|
void physics_extras_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(60); sim.fresh(3);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2];
|
|
// The simulated players share one process, so they share what "the local player" changed:
|
|
// only the host's session sends it.
|
|
const std::vector<std::uint8_t> boosted{1, 2, 3, 4, 5}, calmer{9, 8};
|
|
const auto physics = [&] { for (auto &node : sim.nodes) update_physics_tuning(*node, sim.local, sim.now); };
|
|
dingosdk::set_local_physics_extras(boosted);
|
|
physics();
|
|
sim.run(10);
|
|
check(first.host_extras == boosted && second.host_extras == boosted, "The host's physics extras did not reach its guests");
|
|
check(!host.host_extras, "The host took physics extras for itself");
|
|
|
|
update_physics_tuning(first, sim.local, sim.now);
|
|
std::uint64_t seen{};
|
|
std::vector<std::uint8_t> handed;
|
|
check(dingosdk::session_tuning_enforced() && dingosdk::host_physics_extras(seen, handed) && handed == boosted,
|
|
"A guest's game was not handed the host's physics extras");
|
|
update_physics_tuning(host, sim.local, sim.now);
|
|
check(!dingosdk::session_tuning_enforced(), "A host was told another player sets its physics");
|
|
|
|
auto &late = sim.add();
|
|
sim.run(60); sim.fresh(4);
|
|
check(late.host_extras == boosted, "A player who joined later did not get the host's physics extras");
|
|
|
|
dingosdk::set_local_physics_extras(calmer);
|
|
physics();
|
|
sim.run(10);
|
|
check(first.host_extras == calmer && second.host_extras == calmer && late.host_extras == calmer,
|
|
"A change to the host's physics extras did not reach its guests");
|
|
|
|
// A guest cannot set the others' physics.
|
|
auto forged = packet(first, PacketKind::physics_extras, sim.now);
|
|
forged.extras = {6, 6, 6};
|
|
broadcast(first, forged, true, false, sim.now);
|
|
sim.run(10);
|
|
check(second.host_extras == calmer && late.host_extras == calmer && !host.host_extras,
|
|
"A guest's physics extras were taken for the host's");
|
|
|
|
// Back to the game's own: sent, so no guest keeps what it had.
|
|
dingosdk::set_local_physics_extras({});
|
|
physics();
|
|
sim.run(10);
|
|
check(first.host_extras && first.host_extras->empty() && second.host_extras && second.host_extras->empty(),
|
|
"Guests kept physics extras the host no longer has");
|
|
|
|
// Enforcement off: nothing kept for late joiners, and a guest's game is its own again.
|
|
host.enforce_tuning = false;
|
|
host.roster_dirty = true;
|
|
physics();
|
|
sim.run(20);
|
|
check(host.extras_packet.empty() && !host.sent_extras, "A host that stopped setting everyone's physics kept its extras packet");
|
|
check(!first.enforce_tuning, "The guest was not told the host stopped setting everyone's physics");
|
|
update_physics_tuning(first, sim.local, sim.now);
|
|
check(!dingosdk::session_tuning_enforced(), "A guest stayed under the host's physics after the host let go");
|
|
dingosdk::set_session_tuning_enforced(false);
|
|
dingosdk::set_host_physics_extras({});
|
|
std::cout << "Physics extras: host to guests, late joiners, changes, forged senders and enforcement off passed.\n";
|
|
}
|
|
// A lobby host keeps a guest whose mods change scoring or physics out of linked activities: the roster
|
|
// flags them for everyone and the host passes none of their throwdown messages on.
|
|
void scoring_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(60); sim.fresh(3);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2];
|
|
const auto first_id = first.transport.status().local_id;
|
|
auto *first_at_host = find_peer(host, first_id);
|
|
check(first_at_host && first_at_host->scoring == 0 && !first_at_host->member.scoring,
|
|
"A guest with the game's own scoring did not report it, or was flagged");
|
|
check(first.scoring_sent && first.scoring_sent->first == 0, "The guest's report was not remembered");
|
|
|
|
auto report = packet(first, PacketKind::scoring, sim.now);
|
|
report.scoring = 0xbadbadbadULL;
|
|
report.text = "BigPoints";
|
|
check(first.transport.send(first.host_id, encode_wire(report), true), "Could not queue a scoring report fixture");
|
|
sim.run(40);
|
|
first_at_host = find_peer(host, first_id);
|
|
const auto *first_at_second = find_peer(second, first_id);
|
|
check(first_at_host && first_at_host->member.scoring && first_at_host->scoring_mods == "BigPoints",
|
|
"The host did not flag a guest whose mods change scoring or physics");
|
|
check(first.local_scoring, "The flagged guest does not know it was flagged");
|
|
check(first_at_second && first_at_second->member.scoring, "Other guests do not see the flag");
|
|
check(std::any_of(second.chat.begin(), second.chat.end(),
|
|
[](const auto &line) { return line.text.find("change scoring or physics") != std::string::npos; }),
|
|
"Other guests were not told in chat");
|
|
|
|
for (auto &node : sim.nodes) node->throwdown_inbox.clear();
|
|
send_throwdown(first, {3, 1, 2, 3});
|
|
sim.run(10);
|
|
check(host.throwdown_inbox.empty() && second.throwdown_inbox.empty(),
|
|
"A flagged guest's throwdown message was passed on");
|
|
send_throwdown(second, {4, 5, 6});
|
|
sim.run(10);
|
|
check(host.throwdown_inbox.size() == 1 && first.throwdown_inbox.size() == 1,
|
|
"An unflagged guest's throwdown message stopped reaching the others");
|
|
|
|
check(edit_score_check(host, "off") == "Mods that change scoring or physics are no longer checked.", "score-check off refused");
|
|
sim.run(40);
|
|
check(!find_peer(host, first_id)->member.scoring && !first.local_scoring && !find_peer(second, first_id)->member.scoring,
|
|
"Turning the check off did not clear the flag");
|
|
for (auto &node : sim.nodes) node->throwdown_inbox.clear();
|
|
send_throwdown(first, {3, 1, 2, 3});
|
|
sim.run(10);
|
|
check(host.throwdown_inbox.size() == 1 && second.throwdown_inbox.size() == 1,
|
|
"With the check off, the guest's throwdown messages still did not go through");
|
|
check(edit_score_check(first, "on") != "Mods that change scoring or physics are no longer checked." && first.score_check,
|
|
"A guest changed the host's scoring check");
|
|
|
|
// The host's own mods count too.
|
|
check(edit_score_check(host, "on").find("kept out") != std::string::npos, "score-check on refused");
|
|
mods::simulated_scoring = {true, 0x1234, {"HostPoints"}};
|
|
sim.run(40);
|
|
check(host.local_scoring && find_peer(second, host.transport.status().local_id)->member.scoring,
|
|
"The host's own scoring mod was not flagged");
|
|
mods::simulated_scoring = {true, 0, {}};
|
|
}
|
|
void object_sync_checks() {
|
|
Simulation sim(32);
|
|
sim.local.pose.skater.resize(1); sim.local.pose.board.resize(1);
|
|
for (unsigned i = 0; i < 31; ++i) {
|
|
auto &node = sim.add();
|
|
const NetworkObject object{1, "own_bk_ramp", {static_cast<float>(i), 2, 5}, {0, 0, 0, 1}};
|
|
node.local_objects.replace(std::span(&object, 1));
|
|
}
|
|
sim.run(90); sim.fresh(31);
|
|
auto &last = sim.add();
|
|
const NetworkObject last_object{1, "own_bk_ramp", {32, 3, 5}, {0, 0, 0, 1}};
|
|
last.local_objects.replace(std::span(&last_object, 1));
|
|
sim.run(80); sim.fresh(32);
|
|
for (const auto &node : sim.nodes) {
|
|
check(player_count(*node) == 32, "A full 32-player roster was not admitted");
|
|
for (const auto &peer : node->peers)
|
|
if (peer.handshaken && peer.objects.objects().size() != 1)
|
|
throw std::runtime_error("Object sync: receiver " + std::to_string(node->transport.status().local_id) +
|
|
", owner " + std::to_string(peer.member.id) + ", count " + std::to_string(peer.objects.objects().size()) +
|
|
", status " + node->status);
|
|
}
|
|
auto &owner = *sim.nodes[1];
|
|
const auto owner_id = owner.transport.status().local_id;
|
|
std::vector<NetworkObject> park;
|
|
for (unsigned i = 0; i < 1024; ++i) park.push_back({i + 1ULL, "own_bk_ramp", {static_cast<float>(i), 2, 5}, {0, 0, 0, 1}});
|
|
owner.local_objects.replace(park);
|
|
sim.run(80);
|
|
for (const auto &node : sim.nodes) if (node.get() != &owner)
|
|
check(find_peer(*node, owner_id)->objects.layout() == park, "Chunked full park did not reach every player");
|
|
park[0].position[0] = -15; park[1].rotation = {0, 1, 0, 0}; park.erase(park.begin() + 2);
|
|
const auto before = owner.local_objects.revision();
|
|
owner.local_objects.replace(park);
|
|
const auto delta = owner.local_objects.updates(before);
|
|
check(delta.size() == 1 && delta[0].objects.size() == 2 && delta[0].removed == std::vector<std::uint64_t>{3},
|
|
"Moving/deleting a few objects resent the whole park");
|
|
sim.run(30);
|
|
for (const auto &node : sim.nodes) if (node.get() != &owner)
|
|
check(find_peer(*node, owner_id)->objects.layout() == park, "Live object moves/rotations/deletion failed through the host");
|
|
SteamTransport extra;
|
|
check(!extra.join(sim.nodes[0]->host_id), "A 33rd player bypassed the advertised capacity");
|
|
auto stale = packet(owner, PacketKind::objects, sim.now);
|
|
stale.objects = owner.local_objects.updates(0).front(); stale.world = owner.world + 1;
|
|
check(owner.transport.send(owner.host_id, encode_wire(stale), true), "Could not queue a stale-world fixture");
|
|
sim.run(5);
|
|
check(find_peer(*sim.nodes[0], owner_id)->objects.layout() == park, "Wrong-world object packet changed the live park");
|
|
auto spoof = packet(*sim.nodes[2], PacketKind::objects, sim.now);
|
|
spoof.source = owner_id; spoof.epoch = owner.epoch; spoof.objects = delta[0];
|
|
sim.nodes[2]->transport.send(owner.host_id, encode_wire(spoof), true);
|
|
sim.run(6);
|
|
check(sim.nodes[2]->mode == Mode::off && find_peer(*sim.nodes[0], owner_id)->objects.layout() == park,
|
|
"A guest could publish another player's object edits");
|
|
stop(owner, "Owner left"); sim.run(8);
|
|
for (const auto &node : sim.nodes) if (node->mode != Mode::off) {
|
|
check(!find_peer(*node, owner_id), "A departed owner's object state remained in the roster");
|
|
check(node->local_objects.objects().size() == 1, "Another owner's edits changed a local layout");
|
|
clear_world(*node, sim.now);
|
|
check(!node->local_objects.revision() && std::all_of(node->peers.begin(), node->peers.end(), [](const auto &peer) {
|
|
return !peer.objects.revision() && peer.object_delivery.chunks.empty();
|
|
}), "Map travel retained object state or queued packets from the previous world");
|
|
}
|
|
std::cout << "32-player objects: late join, matching owner IDs, full parks, deltas, delete, spoof rejection, disconnect and world reset passed.\n";
|
|
}
|
|
void tick_settings_checks() {
|
|
auto& configured = session();
|
|
stop(configured, "TPS fixture");
|
|
check(configured.tps == 30, "Default session TPS is not 30");
|
|
for (const auto rate : multiplayer_tick_rates) {
|
|
const auto result = command("host-config", "code 8 " + std::to_string(rate) + " Test lobby");
|
|
check(configured.mode == Mode::host && configured.tps == rate && configured.lobby_name == "Test lobby",
|
|
"Host form did not apply TPS and lobby name");
|
|
(void)result;
|
|
const auto epoch = configured.epoch;
|
|
(void)command("host-config", "code 8 45 Invalid");
|
|
check(configured.epoch == epoch && configured.tps == rate, "Invalid TPS replaced an active session");
|
|
check(configured.status == "Choose 20, 30, 60, or 120 TPS before hosting.", "A refused host left no status for the menu");
|
|
stop(configured, "TPS fixture");
|
|
Simulation sim(4, rate);
|
|
sim.add(); sim.add(); sim.run(70);
|
|
check(sim.nodes[1]->tps == rate, "Joining guest did not adopt host TPS");
|
|
sim.add(); sim.run(70);
|
|
check(sim.nodes.back()->tps == rate, "Late joiner did not adopt host TPS");
|
|
for (const auto& node : sim.nodes) {
|
|
clear_world(*node, sim.now);
|
|
check(node->tps == rate, "Map transition reset host TPS");
|
|
}
|
|
}
|
|
(void)command("host", "code 8 120 is a lobby name");
|
|
check(configured.tps == 30 && configured.lobby_name == "120 is a lobby name", "Legacy host command changed meaning");
|
|
profile_runtime::simulated_profile.clear();
|
|
stop(configured, "Lobby name persistence");
|
|
(void)command("host-config", "code 8 30 Persisted lobby");
|
|
check(configured.lobby_name == "Persisted lobby", "Host did not use the requested lobby name");
|
|
const auto stored = profile_runtime::local_value("Host.LobbyName");
|
|
check(stored && stored->is_string() && stored->string() == "Persisted lobby",
|
|
"Host did not save the lobby name to the profile");
|
|
stop(configured, "Lobby name persistence");
|
|
configured.host_preferences.loaded = false;
|
|
(void)command("host-config", "code 8 30");
|
|
check(configured.lobby_name == "Persisted lobby", "Empty rehost did not reuse the saved lobby name");
|
|
const auto still_stored = profile_runtime::local_value("Host.LobbyName");
|
|
check(still_stored && still_stored->is_string() && still_stored->string() == "Persisted lobby",
|
|
"Empty rehost wiped the saved lobby name");
|
|
// Whose physics guests skate with is the host's to switch, and is remembered for next time.
|
|
check(configured.enforce_tuning, "A new lobby did not start with the host's physics for everyone");
|
|
(void)command("tuning-enforce", "off");
|
|
check(!configured.enforce_tuning && !configured.host_preferences.enforce_tuning && configured.roster_dirty,
|
|
"The host could not let guests skate with their own physics");
|
|
(void)command("tuning-enforce", "toggle");
|
|
check(configured.enforce_tuning && configured.host_preferences.enforce_tuning, "The host could not switch its physics for everyone back on");
|
|
stop(configured, "TPS fixture complete");
|
|
}
|
|
void pacing_checks() {
|
|
bool passed = true;
|
|
// The native callback runs once per rendered frame, not on a 50 ms timer.
|
|
for (const auto tps : multiplayer_tick_rates) for (const unsigned fps : {30U, 59U, 60U, 75U, 144U}) {
|
|
auto echo_storage = std::make_unique<Session>();
|
|
auto &echo = *echo_storage;
|
|
echo.mode = Mode::echo;
|
|
echo.tps = tps;
|
|
echo.secret = 73; echo.epoch = 91; echo.map = 42;
|
|
echo.transport.open();
|
|
NativeFrame local;
|
|
local.ready = true;
|
|
local.pose.skater.resize(395);
|
|
local.pose.board.resize(17);
|
|
for (unsigned frame = 0; frame < fps * 6; ++frame) {
|
|
const auto now = 10000000ULL + std::uint64_t{frame} * 1000000 / fps;
|
|
send_local(echo, local, now);
|
|
}
|
|
const auto count = echo.peers[0].pose_count;
|
|
std::cout << "Pacing " << tps << " TPS at " << fps << " FPS: " << count << " poses / 6 seconds\n";
|
|
const auto expected = std::min(tps, fps) * 6;
|
|
passed &= count >= expected - 1 && count <= expected;
|
|
const auto stalled = 20000000ULL;
|
|
send_local(echo, local, stalled);
|
|
send_local(echo, local, stalled);
|
|
passed &= echo.peers[0].pose_count == count + 1;
|
|
}
|
|
// Relay arrivals alternate 35/65 ms, even though the sender is exactly 20 TPS.
|
|
// Distance thinning must use source time; nearby delivery must not thin again.
|
|
for (const bool uneven_source : {false, true}) for (const auto distance : {1.f, 100.f, 250.f}) {
|
|
Simulation sim;
|
|
auto &host = sim.add();
|
|
sim.add(); sim.add();
|
|
sim.run(30);
|
|
const auto source_id = sim.nodes[1]->transport.status().local_id;
|
|
const auto target_id = sim.nodes[2]->transport.status().local_id;
|
|
auto *target = find_peer(host, target_id);
|
|
target->pose_delivery = {};
|
|
target->direct_routes.clear();
|
|
target->route_reported = 0;
|
|
auto pose = packet(*sim.nodes[1], PacketKind::pose, sim.now);
|
|
pose.pose = sim.local.pose;
|
|
const auto before = host.transport.status().sent;
|
|
for (unsigned tick = 0; tick < 120; ++tick) {
|
|
pose.time_us = sim.now + (uneven_source
|
|
? ((tick * 3ULL + 1) / 2) * 1000000 / 30 : tick * 50000ULL);
|
|
++pose.sequence;
|
|
const auto arrival = pose.time_us + 100000 + (tick % 2 ? 15000 : 0);
|
|
target->latest_root = pose.pose.root;
|
|
target->latest_root->position[2] += distance;
|
|
target->pose_arrival = arrival;
|
|
broadcast(host, pose, false, true, arrival, source_id);
|
|
}
|
|
const auto count = host.transport.status().sent - before;
|
|
const auto expected = distance < 60 ? 120U : distance < 170 ? 60U : 30U;
|
|
std::cout << "Jittered relay " << distance << " m, source FPS=" << (uneven_source ? 30 : 20)
|
|
<< ": " << count << " / " << expected << " poses\n";
|
|
passed &= count == expected;
|
|
}
|
|
check(passed, "Frame/arrival timing reduced pose TPS or caused a catch-up burst");
|
|
std::cout << "Pacing: render-frame cadence, stalls and jittered 20/10/5 TPS relay checks passed.\n";
|
|
}
|
|
void priority_mesh_checks() {
|
|
SimulatedNetwork::audit_lanes = SimulatedNetwork::cosmetics_first = true;
|
|
SimulatedNetwork::lane_sends = {};
|
|
Simulation sim;
|
|
auto &host = sim.add();
|
|
auto &guest = sim.add();
|
|
erase_key(host.password);
|
|
erase_key(guest.password);
|
|
SimulatedNetwork::hold_control_to = guest.transport.status().local_id;
|
|
sim.run(6);
|
|
const auto *peer = find_peer(guest, host.host_id);
|
|
check(peer && !peer->handshaken && !peer->pending_cosmetics.empty() && !peer->appearance.value(),
|
|
"Early cosmetic was lost or applied before welcome/admission");
|
|
SimulatedNetwork::hold_control_to = 0;
|
|
sim.run(20);
|
|
sim.fresh(2);
|
|
for (unsigned i = 2; i < 8; ++i) { auto &node = sim.add(); erase_key(node.password); }
|
|
sim.run(80);
|
|
sim.fresh(8);
|
|
auto outfit = packet(host, PacketKind::cosmetics, sim.now);
|
|
outfit.appearance = {{skater_recipe_key, 2, {}, {{1, "Updated outfit", {}}}},
|
|
{board_recipe_key, 1, {}, {{2, "Updated deck", {}}}}};
|
|
broadcast(host, outfit, true, false, sim.now);
|
|
SimulatedNetwork::lose_unreliable = true;
|
|
sim.run(80);
|
|
sim.fresh(8);
|
|
for (std::size_t i = 1; i < sim.nodes.size(); ++i)
|
|
check(find_peer(*sim.nodes[i], host.host_id)->appearance.value() == outfit.appearance,
|
|
"Cosmetic update failed after prioritized baseline/delta delivery");
|
|
auto &target = *sim.nodes[1];
|
|
auto *host_link = find_peer(target, host.host_id);
|
|
auto spoof = outfit;
|
|
spoof.epoch += 1000;
|
|
spoof.sequence += 10;
|
|
defer_cosmetics(*host_link, spoof, sim.now);
|
|
receive_cosmetics(target, sim.local, sim.now);
|
|
check(host_link->appearance.value() == outfit.appearance, "Deferred outfit bypassed roster epoch validation");
|
|
receive_cosmetics(target, sim.local, sim.now + 10000001);
|
|
check(host_link->pending_cosmetics.empty(), "Unadmitted cosmetics retained beyond deadline");
|
|
for (unsigned i = 0; i < max_players + 5; ++i) {
|
|
spoof.source = i + 1;
|
|
defer_cosmetics(*host_link, spoof, sim.now);
|
|
}
|
|
check(host_link->pending_cosmetics.size() == max_players, "Pending cosmetics exceeded source bound");
|
|
clear_world(target, sim.now);
|
|
check(find_peer(target, host.host_id)->pending_cosmetics.empty(), "Old outfits survived world reset");
|
|
check(std::all_of(SimulatedNetwork::lane_sends.begin(), SimulatedNetwork::lane_sends.end(),
|
|
[](auto count) { return count > 0; }), "Not all Steam traffic lanes were exercised");
|
|
SimulatedNetwork::lose_unreliable = false;
|
|
std::cout << "Priority mesh: early cosmetics before admission, eight-player reordered delivery, "
|
|
"stream/reference lanes, packet loss, outfit updates, epoch validation and bounded reset passed.\n";
|
|
}
|
|
struct MapPair {
|
|
static inline unsigned loads{};
|
|
static inline bool missing{};
|
|
// Match runtime ownership: large session buffers do not fit on the
|
|
// default Windows stack when two map-transition peers are nested.
|
|
std::unique_ptr<Session> host_storage = std::make_unique<Session>();
|
|
std::unique_ptr<Session> guest_storage = std::make_unique<Session>();
|
|
Session &host = *host_storage, &guest = *guest_storage;
|
|
NativeFrame host_frame;
|
|
std::string current = "Levels/Root/Root|Levels/Other/Other";
|
|
bool ready = true;
|
|
std::uint64_t now = 10000000;
|
|
static MapLoadResult loader(std::string_view, bool submitted, std::string &detail) {
|
|
if (missing) {
|
|
detail = "Map not installed.";
|
|
return MapLoadResult::missing;
|
|
}
|
|
if (!submitted) ++loads;
|
|
return submitted ? MapLoadResult::waiting : MapLoadResult::queued;
|
|
}
|
|
MapPair(bool password = false, bool correct = true) {
|
|
loads = 0;
|
|
missing = false;
|
|
host_frame.ready = true;
|
|
host_frame.pose.skater.resize(1);
|
|
host.mode = Mode::host;
|
|
host.secret = guest.secret = 789;
|
|
host.epoch = 101;
|
|
guest.epoch = 102;
|
|
host.map_name = "Levels/Root/Root|Levels/Beach/Beach";
|
|
host.map = map_hash(host.map_name);
|
|
host.transport.host(8);
|
|
host.host_id = guest.host_id = host.transport.status().local_id;
|
|
guest.transport.join(host.host_id);
|
|
guest.mode = Mode::join;
|
|
guest.awaiting_map = true;
|
|
guest.join_started = now;
|
|
if (password) {
|
|
host.password = password_key("test room", host.secret);
|
|
guest.password = password_key(correct ? "test room" : "wrong password", guest.secret);
|
|
}
|
|
}
|
|
void run(unsigned frames) {
|
|
for (unsigned i = 0; i < frames; ++i) {
|
|
now += network_tick_us;
|
|
if (host.mode != Mode::off) networking(host, host_frame, now);
|
|
if (guest.mode == Mode::off) continue;
|
|
NativeFrame local;
|
|
if (prepare_join_map(guest, ready, current, loader, now)) {
|
|
local = host_frame;
|
|
guest.map = map_hash(current);
|
|
guest.map_name = current;
|
|
}
|
|
if (guest.mode != Mode::off) networking(guest, local, now);
|
|
}
|
|
}
|
|
};
|
|
void map_checks() {
|
|
Packet offer;
|
|
offer.kind = PacketKind::map_offer;
|
|
offer.session = 1;
|
|
offer.epoch = 2;
|
|
offer.destination = "Levels/Root/Root|Levels/Beach/Beach";
|
|
offer.map = map_hash(offer.destination);
|
|
offer.map_authorized = true;
|
|
const auto bytes = encode(offer);
|
|
const auto decoded = decode(bytes);
|
|
check(decoded && decoded->destination == offer.destination && decoded->map_authorized,
|
|
"Host map offer did not round trip");
|
|
for (std::size_t i = 0; i < bytes.size(); ++i)
|
|
check(!decode(std::span(bytes.data(), i)), "Truncated map offer accepted");
|
|
auto invalid = bytes;
|
|
invalid[packet_header_size + 72] = 2;
|
|
check(!decode(invalid), "Invalid map authorization flag accepted");
|
|
invalid = bytes;
|
|
invalid.back() ^= 1;
|
|
check(!decode(invalid), "Map destination/hash mismatch accepted");
|
|
check(!valid_map_destination("Levels/Root/Root") &&
|
|
!valid_map_destination("Levels/Root|Bad\nLevel") &&
|
|
!valid_map_destination("Levels/Root|Level|Other") &&
|
|
!valid_map_destination("|Level") && valid_map_destination("Levels/Root/Root|"),
|
|
"Invalid destination syntax accepted");
|
|
for (bool password : {false, true}) {
|
|
MapPair pair(password);
|
|
pair.run(65);
|
|
check(pair.guest.join_map_authorized && pair.guest.map_load_submitted && MapPair::loads == 1,
|
|
"Authorized cross-map join did not queue exactly one load");
|
|
check(!find_peer(pair.host, pair.guest.transport.status().local_id)->handshaken,
|
|
"Guest admitted before host map loaded");
|
|
pair.ready = false;
|
|
pair.run(400); // Well beyond the old eight-second handshake timeout.
|
|
check(pair.guest.mode == Mode::join && MapPair::loads == 1,
|
|
"Loading disconnected the guest or queued duplicate loads");
|
|
pair.current = pair.host.map_name;
|
|
pair.run(4);
|
|
check(pair.guest.awaiting_map, "Map identity alone bypassed skater readiness");
|
|
pair.ready = true;
|
|
pair.run(4);
|
|
check(find_peer(pair.guest, pair.host.host_id)->handshaken &&
|
|
find_peer(pair.host, pair.guest.transport.status().local_id)->handshaken,
|
|
"Guest failed to complete admission after native loading");
|
|
}
|
|
{
|
|
MapPair pair;
|
|
pair.current = pair.host.map_name;
|
|
pair.run(10);
|
|
check(MapPair::loads == 0 && find_peer(pair.guest, pair.host.host_id)->handshaken,
|
|
"Same-map join unnecessarily reloaded the level");
|
|
}
|
|
{
|
|
MapPair pair(true, false);
|
|
pair.run(65);
|
|
check(pair.guest.mode == Mode::off && MapPair::loads == 0,
|
|
"Wrong password triggered a map load");
|
|
}
|
|
{
|
|
MapPair pair(true);
|
|
erase_key(pair.guest.password);
|
|
pair.run(20);
|
|
check(pair.guest.mode == Mode::off && MapPair::loads == 0,
|
|
"Missing password triggered a map load");
|
|
}
|
|
{
|
|
MapPair pair;
|
|
MapPair::missing = true;
|
|
pair.run(20);
|
|
const auto said = "The host is on Beach, which is not installed on this PC. Install its map mod and join again.";
|
|
check(pair.guest.mode == Mode::off && pair.guest.status == said && pair.guest.leave_notice == said,
|
|
"Missing map did not end the pending join naming the map");
|
|
}
|
|
{
|
|
MapPair pair;
|
|
pair.run(20);
|
|
stop(pair.guest, "Cancelled");
|
|
pair.current = pair.host.map_name;
|
|
pair.run(20);
|
|
check(pair.guest.mode == Mode::off && !pair.guest.awaiting_map && pair.guest.join_destination.empty(),
|
|
"Cancelled map load rejoined the server");
|
|
}
|
|
{
|
|
MapPair pair;
|
|
pair.run(20);
|
|
pair.now += 180000001;
|
|
pair.run(1);
|
|
check(pair.guest.mode == Mode::off, "Loading join did not have a bounded deadline");
|
|
}
|
|
{
|
|
MapPair pair;
|
|
pair.run(20);
|
|
stop(pair.host, "Host left");
|
|
pair.run(1);
|
|
check(pair.guest.mode == Mode::off, "Host departure left a pending map join active");
|
|
}
|
|
std::cout << "Automatic map joining: codec, same/different maps, password-before-load, slow loading, "
|
|
"skater readiness, missing maps, cancellation, timeout and host departure passed.\n";
|
|
}
|
|
struct TravelSimulation {
|
|
Simulation sim;
|
|
std::vector<std::string> current, queued;
|
|
std::vector<bool> ready;
|
|
std::vector<unsigned> loads;
|
|
static inline TravelSimulation *active{};
|
|
static inline std::size_t node{};
|
|
const std::string beach = "Levels/Root/Root|Levels/Beach/Beach";
|
|
const std::string city = "Levels/Root/Root|Levels/City/City";
|
|
explicit TravelSimulation(unsigned count) {
|
|
current.resize(count, beach);
|
|
queued.resize(count);
|
|
ready.resize(count, true);
|
|
loads.resize(count);
|
|
for (unsigned i = 0; i < count; ++i) {
|
|
auto &s = sim.add();
|
|
s.map = map_hash(beach);
|
|
s.map_name = beach;
|
|
s.invite = format_invite({s.host_id, s.secret});
|
|
auto cosmetic = *decode_wire(s.cosmetic_packet);
|
|
cosmetic.map = s.map;
|
|
s.cosmetic_packet = encode_wire(cosmetic);
|
|
}
|
|
run(80);
|
|
sim.fresh(count);
|
|
}
|
|
static MapLoadResult loader(std::string_view destination, bool submitted, std::string &) {
|
|
if (submitted) return MapLoadResult::waiting;
|
|
active->queued[node] = destination;
|
|
active->ready[node] = false;
|
|
++active->loads[node];
|
|
return MapLoadResult::queued;
|
|
}
|
|
void finish(std::size_t i) {
|
|
if (!queued[i].empty()) current[i] = queued[i];
|
|
ready[i] = true;
|
|
}
|
|
void run(unsigned frames) {
|
|
active = this;
|
|
for (unsigned frame = 0; frame < frames; ++frame) {
|
|
sim.now += network_tick_us;
|
|
for (node = 0; node < sim.nodes.size(); ++node) {
|
|
auto &s = *sim.nodes[node];
|
|
if (s.mode == Mode::off) continue;
|
|
refresh_host_choices(s, sim.now);
|
|
NativeFrame local;
|
|
if (prepare_join_map(s, ready[node], current[node], loader, sim.now) && ready[node]) {
|
|
local = sim.local;
|
|
local.pose.root.position[0] = static_cast<float>(sim.now - 10000000) / 1000000.f;
|
|
local.pose.root.position[2] = static_cast<float>(node);
|
|
sim.local.pose.root.position[0] = local.pose.root.position[0];
|
|
}
|
|
if (s.mode == Mode::off || !observe_local_world(s, local, current[node], sim.now)) continue;
|
|
networking(s, local, sim.now);
|
|
if (s.mode == Mode::off || !world_playing(s, local)) continue;
|
|
if (s.cosmetic_packet.empty()) {
|
|
auto c = packet(s, PacketKind::cosmetics, sim.now);
|
|
c.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit" + std::to_string(node), {}}}},
|
|
{board_recipe_key, 1, {}, {{2, "Deck", {}}}}};
|
|
s.cosmetic_packet = encode_wire(c);
|
|
broadcast(s, c, true, false, sim.now);
|
|
}
|
|
send_local(s, local, sim.now);
|
|
auto sound = packet(s, PacketKind::audio, sim.now);
|
|
sound.audio = {AudioSample{}};
|
|
sound.audio[0].state.values[1] = static_cast<float>(node) + .5f;
|
|
broadcast(s, sound, true, true, sim.now);
|
|
}
|
|
}
|
|
}
|
|
void connected() {
|
|
const auto count = static_cast<unsigned>(sim.nodes.size());
|
|
for (const auto &s : sim.nodes) {
|
|
check(s->mode != Mode::off && player_count(*s) == count, "Map change lost room membership");
|
|
check(s->secret == 73 && s->password.has_value(), "Map change replaced the invitation/password");
|
|
}
|
|
check(sim.nodes[0]->transport.status().peers.size() == count - 1,
|
|
"Map change closed a guest's host connection");
|
|
}
|
|
};
|
|
void travel_checks() {
|
|
Packet state;
|
|
state.kind = PacketKind::world_state;
|
|
state.session = 1;
|
|
state.epoch = 2;
|
|
state.world = 3;
|
|
state.map = 0;
|
|
auto encoded = encode(state);
|
|
check(decode(encoded)->destination.empty(), "Unknown-destination loading notice rejected");
|
|
state.destination = "Levels/Root/Root|Levels/Beach/Beach";
|
|
state.map = map_hash(state.destination);
|
|
state.world_ready = true;
|
|
encoded = encode(state);
|
|
check(decode(encoded)->world == 3 && decode(encoded)->world_ready, "Transition state lost generation/readiness");
|
|
for (std::size_t i = 0; i < encoded.size(); ++i)
|
|
check(!decode(std::span(encoded.data(), i)), "Truncated transition accepted");
|
|
encoded[packet_header_size + 32] = 2;
|
|
check(!decode(encoded), "Invalid transition flag accepted");
|
|
for (const unsigned count : {2U, 8U}) {
|
|
TravelSimulation test(count);
|
|
auto &host = *test.sim.nodes[0];
|
|
const auto invite = host.invite;
|
|
const auto old_pose = packet(host, PacketKind::pose, test.sim.now);
|
|
auto stale = old_pose;
|
|
stale.pose = test.sim.local.pose;
|
|
stale.pose.root.position[0] = 9999;
|
|
const auto stale_wire = encode_wire(stale);
|
|
begin_host_world(host, test.city, test.sim.now);
|
|
test.ready[0] = false;
|
|
test.run(240);
|
|
test.connected();
|
|
for (unsigned i = 1; i < count; ++i)
|
|
check(test.loads[i] == 1 && test.queued[i] == test.city, "Guest did not queue the host map exactly once");
|
|
test.current[0] = test.city;
|
|
test.ready[0] = true;
|
|
for (unsigned i = 1; i + 1 < count; ++i) test.finish(i);
|
|
test.run(240); // One guest still loading, others can skate.
|
|
test.connected();
|
|
check(!host.travelling && !test.sim.nodes.back()->started_map,
|
|
"Slow guest blocked the host or used an unloaded world");
|
|
test.finish(count - 1);
|
|
test.run(100);
|
|
test.connected();
|
|
test.sim.fresh(count);
|
|
check(host.invite == invite, "Host map switch changed the share code");
|
|
// Same-map reload must preserve admission without an unnecessary guest load.
|
|
const auto previous_loads = test.loads;
|
|
begin_host_world(host, test.city, test.sim.now);
|
|
test.ready[0] = false;
|
|
test.run(240);
|
|
test.connected();
|
|
check(test.loads == previous_loads, "Same-map host reload reloaded guests unnecessarily");
|
|
test.ready[0] = true;
|
|
test.run(100);
|
|
test.sim.fresh(count);
|
|
// Returning to A cannot revive packets from the first A world.
|
|
begin_host_world(host, test.beach, test.sim.now);
|
|
test.ready[0] = false;
|
|
test.run(30);
|
|
test.current[0] = test.beach;
|
|
test.ready[0] = true;
|
|
for (unsigned i = 1; i < count; ++i) test.finish(i);
|
|
test.run(100);
|
|
test.connected();
|
|
test.sim.fresh(count);
|
|
for (unsigned i = 1; i < count; ++i)
|
|
host.transport.send(test.sim.nodes[i]->transport.status().local_id, stale_wire, false, false, TrafficLane::gameplay);
|
|
test.run(2);
|
|
test.sim.fresh(count);
|
|
check(host.world == 4, "World generation did not advance on every host load");
|
|
}
|
|
{
|
|
TravelSimulation test(2);
|
|
auto &host = *test.sim.nodes[0];
|
|
begin_host_world(host, {}, test.sim.now); // Native load outside the menu.
|
|
test.ready[0] = false;
|
|
test.run(240);
|
|
test.connected();
|
|
check(test.loads[1] == 0, "Unknown host destination queued an invalid native load");
|
|
test.current[0] = test.city;
|
|
test.ready[0] = true;
|
|
test.run(30);
|
|
test.finish(1);
|
|
test.run(100);
|
|
test.sim.fresh(2);
|
|
}
|
|
{
|
|
TravelSimulation test(2);
|
|
auto &host = *test.sim.nodes[0];
|
|
begin_host_world(host, test.city, test.sim.now);
|
|
test.ready[0] = false;
|
|
test.run(30);
|
|
// The native host load failed and returned to its old map.
|
|
test.ready[0] = true;
|
|
test.run(10);
|
|
check(host.world == 3 && host.map == map_hash(test.beach), "Failed host load did not publish a rollback");
|
|
test.current[1] = test.city; // Finish the superseded guest request first.
|
|
test.ready[1] = true;
|
|
test.run(20);
|
|
check(test.queued[1] == test.beach && test.loads[1] == 2,
|
|
"New destination failed to replace a guest's in-flight load");
|
|
test.finish(1);
|
|
test.run(100);
|
|
test.connected();
|
|
test.sim.fresh(2);
|
|
}
|
|
{
|
|
TravelSimulation test(3);
|
|
auto &host = *test.sim.nodes[0];
|
|
begin_host_world(host, test.city, test.sim.now);
|
|
test.ready[0] = false;
|
|
test.run(30);
|
|
stop(*test.sim.nodes[2], "Cancelled during loading");
|
|
test.current[0] = test.city;
|
|
test.ready[0] = true;
|
|
test.finish(1);
|
|
test.run(100);
|
|
check(player_count(host) == 2 && player_count(*test.sim.nodes[1]) == 2 &&
|
|
test.sim.nodes[2]->mode == Mode::off, "One cancelled guest disrupted the remaining room");
|
|
test.sim.fresh(2);
|
|
auto spoof = packet(*test.sim.nodes[1], PacketKind::world_state, test.sim.now);
|
|
spoof.world = host.world + 1;
|
|
spoof.destination = test.beach;
|
|
spoof.map = map_hash(test.beach);
|
|
test.sim.nodes[1]->transport.send(host.host_id, encode_wire(spoof), true, false);
|
|
test.run(2);
|
|
check(host.mode == Mode::host && host.world == 2 && host.map == map_hash(test.city),
|
|
"Guest was able to redirect the host to another map");
|
|
}
|
|
std::cout << "Host travel: 2/8 players, retained membership/password/code, staggered loading, "
|
|
"same-map reload, A-B-A stale packets, poses/board/audio/cosmetics, native-load fallback, "
|
|
"failed-load rollback, superseded loads, cancellation and host-only authority passed.\n";
|
|
}
|
|
void session_controls_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 4; ++i) sim.add();
|
|
sim.run(80);
|
|
auto &host = *sim.nodes[0];
|
|
auto &guest = *sim.nodes[1];
|
|
const auto roster = [&] {
|
|
auto p = packet(host, PacketKind::roster, sim.now);
|
|
p.capacity = host.capacity;
|
|
p.object_placement = host.object_placement;
|
|
p.members.push_back({p.source, host.epoch, "Host"});
|
|
for (const auto &peer : host.peers) if (peer.handshaken) p.members.push_back(peer.member);
|
|
// Everyone in one party led by the host, as a roster may list them.
|
|
for (auto &m : p.members) {
|
|
m.party = p.members.size() > 1 ? lobby_party : 0;
|
|
m.party_leader = m.party && m.id == p.source;
|
|
}
|
|
return p;
|
|
};
|
|
const auto stale = roster();
|
|
constexpr auto everyone = ObjectPlacement::everyone, host_only = ObjectPlacement::host_only;
|
|
check(edit_object_placement(guest, "off").starts_with("Only the session host") &&
|
|
guest.object_placement == everyone, "Guest changed object placement");
|
|
(void)edit_object_placement(host, "off");
|
|
sim.run(4);
|
|
for (const auto &s : sim.nodes) check(s->object_placement == host_only, "Live placement lock was not delivered");
|
|
check(object_placement_allowed(host.object_placement, true) &&
|
|
!object_placement_allowed(guest.object_placement, false),
|
|
"Host-only placement did not exempt the host alone");
|
|
(void)edit_object_placement(host, "invalid");
|
|
check(host.object_placement == host_only, "Invalid permission changed the policy");
|
|
send_required(host, guest.transport.status().local_id, encode_wire(stale));
|
|
sim.run(4);
|
|
check(guest.object_placement == host_only, "Stale roster reenabled placement");
|
|
sim.add(); sim.run(70);
|
|
check(sim.nodes.back()->object_placement == host_only, "Late joiner missed the placement policy");
|
|
auto bytes = encode(roster());
|
|
check(decode(bytes) && decode(bytes)->object_placement == host_only, "Placement policy did not round trip");
|
|
// Roster tail: tps, placement, forced layers, layer count (2 bytes), layer
|
|
// modes, voice policy (5 bytes), voice range (2 bytes).
|
|
const auto placement_at = bytes.size() - 2 - 5 - world_layers().size() - 2 - 2;
|
|
check(!decode(std::span(bytes).first(placement_at)), "Missing permission byte accepted");
|
|
bytes[placement_at] = 3;
|
|
check(!decode(bytes), "Invalid permission byte accepted");
|
|
auto forged = roster();
|
|
forged.source = sim.nodes[2]->transport.status().local_id;
|
|
forged.epoch = sim.nodes[2]->epoch;
|
|
forged.object_placement = everyone;
|
|
check(send_packet(*sim.nodes[2], guest.transport.status().local_id, forged, true, false),
|
|
"Permission spoof fixture could not send");
|
|
sim.run(3);
|
|
check(guest.object_placement == host_only, "Guest spoof replaced host placement policy");
|
|
(void)edit_object_placement(host, "on"); sim.run(4);
|
|
for (const auto &s : sim.nodes) check(s->object_placement == everyone, "Placement enable was not delivered");
|
|
check(edit_object_placement(guest, "next").starts_with("Only the session host") &&
|
|
guest.object_placement == everyone, "Guest cycled object placement");
|
|
for (const auto policy : {host_only, ObjectPlacement::nobody, everyone, host_only}) {
|
|
(void)edit_object_placement(host, "next"); sim.run(4);
|
|
for (const auto &s : sim.nodes)
|
|
check(s->object_placement == policy, "Repeated placement cycle reused a stale value");
|
|
}
|
|
(void)edit_object_placement(host, "next");
|
|
(void)edit_object_placement(host, "next");
|
|
(void)edit_object_placement(host, "next");
|
|
check(host.object_placement == host_only, "Back-to-back placement cycles did not use live state");
|
|
(void)edit_object_placement(host, "on");
|
|
const auto victim = sim.nodes.back()->transport.status().local_id;
|
|
const auto epoch = sim.nodes.back()->epoch;
|
|
const auto target = std::to_string(victim) + " " + std::to_string(epoch);
|
|
check(kick_player(guest, target).starts_with("Only the session host"), "Guest could kick a player");
|
|
(void)kick_player(host, std::to_string(host.host_id) + " " + std::to_string(host.epoch));
|
|
check(host.mode == Mode::host, "Host kicked itself");
|
|
(void)kick_player(host, std::to_string(victim) + " " + std::to_string(epoch + 1));
|
|
check(find_peer(host, victim), "Stale kick removed a player with a different epoch");
|
|
(void)edit_object_placement(host, "off"); sim.run(3);
|
|
removed_object_owners.clear();
|
|
check(kick_player(host, target).find("was kicked") != std::string::npos, "Host kick failed");
|
|
sim.run(10);
|
|
check(std::count(removed_object_owners.begin(), removed_object_owners.end(), std::pair{victim, epoch}) >= 4,
|
|
"Kick did not clean the departing owner's objects on every remaining peer");
|
|
check(sim.nodes.back()->mode == Mode::off && sim.nodes.back()->object_placement == everyone,
|
|
"Kicked guest did not disconnect and restore offline placement");
|
|
for (std::size_t i = 0; i + 1 < sim.nodes.size(); ++i) {
|
|
const auto &s = *sim.nodes[i];
|
|
check(!find_peer(*sim.nodes[i], victim), "Kicked player survived in the roster");
|
|
check(std::none_of(s.transport.status().peers.begin(), s.transport.status().peers.end(),
|
|
[&](const auto &p) { return p.id == victim; }), "Kick retained a direct transport");
|
|
check(s.mode != Mode::off && s.object_placement == host_only, "Kick changed another player's session");
|
|
}
|
|
publish(host);
|
|
check(host.view.host_id == host.host_id && !host.view.local_name.empty() &&
|
|
host.view.roster.front().epoch != 0, "Session view omitted player identity");
|
|
std::cout << "Session controls: live policy, late join, malformed/stale/spoof rejection, host-only kick, epoch checks and peer cleanup passed.\n";
|
|
}
|
|
void guest_building_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(80);
|
|
auto &host = *sim.nodes[0];
|
|
auto &builder = *sim.nodes[1];
|
|
auto &viewer = *sim.nodes[2];
|
|
const auto builder_id = builder.transport.status().local_id;
|
|
const auto host_id = host.host_id;
|
|
const auto seen = [&](Session &node, std::uint64_t owner) {
|
|
return find_peer(node, owner)->objects.objects().size();
|
|
};
|
|
const auto place = [](Session &node, unsigned count) {
|
|
std::vector<NetworkObject> layout;
|
|
for (unsigned i = 0; i < count; ++i)
|
|
layout.push_back({i + 1ULL, "own_bk_ramp", {static_cast<float>(i), 2, 5}, {0, 0, 0, 1}});
|
|
node.local_objects.replace(layout);
|
|
};
|
|
place(builder, 1); sim.run(10);
|
|
check(seen(viewer, builder_id) == 1, "Guest objects did not reach other guests while building was allowed");
|
|
(void)edit_object_placement(host, "host"); sim.run(4);
|
|
// A modified guest ignores its own placement lock and keeps publishing.
|
|
place(builder, 3); sim.run(10);
|
|
check(find_peer(host, builder_id)->objects.objects().size() == 3,
|
|
"Host lost the guest's upload, so its revisions would desync on re-enable");
|
|
check(seen(viewer, builder_id) == 1 && find_peer(host, builder_id)->shared.objects().size() == 1,
|
|
"A guest bypassed disabled building and published objects");
|
|
place(host, 2); sim.run(10);
|
|
check(seen(viewer, host_id) == 2 && seen(builder, host_id) == 2, "Disabling guest building blocked the host");
|
|
auto &late = sim.add(); sim.run(70);
|
|
check(seen(late, builder_id) == 1, "A late joiner received objects placed while building was disabled");
|
|
(void)edit_object_placement(host, "nobody"); sim.run(4);
|
|
check(!simulated_placement_allowed, "Session-wide lock did not lock the host's own tools");
|
|
place(builder, 5); sim.run(10);
|
|
check(seen(viewer, builder_id) == 1, "A guest bypassed a session-wide placement lock");
|
|
(void)edit_object_placement(host, "everyone"); sim.run(10);
|
|
for (auto *node : {&viewer, &late})
|
|
check(seen(*node, builder_id) == 5, "Re-enabling guest building did not publish the current layout");
|
|
place(builder, 6); sim.run(10);
|
|
check(seen(viewer, builder_id) == 6, "Guest edits stopped syncing after building was re-enabled");
|
|
|
|
place(viewer, 2); sim.run(10);
|
|
check(clear_guest_objects(viewer, {}).starts_with("Only the session host") && seen(builder, viewer.transport.status().local_id) == 2,
|
|
"A guest deleted other players' objects");
|
|
simulated_guest_wipes = 0;
|
|
check(clear_guest_objects(host, {}) == "Deleted 8 guest objects.", "Host delete reported the wrong object count");
|
|
sim.run(10);
|
|
check(seen(viewer, builder_id) == 0 && seen(builder, viewer.transport.status().local_id) == 0 &&
|
|
seen(late, builder_id) == 0, "Deleting guest objects did not remove them for everyone");
|
|
check(seen(viewer, host_id) == 2, "Deleting guest objects removed the host's own objects");
|
|
check(simulated_guest_wipes >= 3 && !builder.clear_pending && !viewer.clear_pending,
|
|
"Guests were not told to delete their own session objects");
|
|
// A modified guest ignores the wipe and re-sends (and edits) its old objects.
|
|
std::vector<NetworkObject> stale;
|
|
for (unsigned i = 0; i < 6; ++i) stale.push_back({i + 1ULL, "own_bk_ramp", {static_cast<float>(i), 9, 5}, {0, 0, 0, 1}});
|
|
builder.local_objects.replace(stale); sim.run(10);
|
|
check(seen(viewer, builder_id) == 0, "A guest restored objects the host deleted");
|
|
stale.push_back({100, "own_bk_ramp", {40, 2, 5}, {0, 0, 0, 1}});
|
|
builder.local_objects.replace(stale); sim.run(10);
|
|
check(seen(viewer, builder_id) == 1, "New guest objects stopped syncing after a delete");
|
|
const auto wipes = simulated_guest_wipes;
|
|
auto &after = sim.add(); sim.run(70);
|
|
check(simulated_guest_wipes == wipes && !after.clear_pending && after.object_clears == host.object_clears,
|
|
"A player joining after a delete wiped their own objects");
|
|
|
|
const auto late_id = late.transport.status().local_id;
|
|
(void)kick_player(host, std::to_string(late_id) + " " + std::to_string(late.epoch));
|
|
sim.run(6);
|
|
check(late.mode == Mode::off, "Kicked guest stayed connected");
|
|
// Rejoin with the same Steam identity and a fresh session epoch.
|
|
late.mode = Mode::join;
|
|
late.epoch += 1000;
|
|
late.started_map = true;
|
|
late.map = 42;
|
|
late.host_id = host_id;
|
|
late.password = password_key("test room", late.secret);
|
|
late.transport.join(host_id);
|
|
sim.run(60);
|
|
check(late.mode == Mode::off && (!find_peer(host, late_id) || !find_peer(host, late_id)->handshaken),
|
|
"A kicked player rejoined the session");
|
|
check(player_count(host) == 4, "Rejecting a kicked player disturbed the other guests");
|
|
stop(host, "Host restarted");
|
|
check(host.banned.empty(), "Kick bans outlived the session");
|
|
std::cout << "Guest building: host-enforced freeze, patched-guest rejection, host exemption, late join, re-enable, guest object deletion and "
|
|
"kick bans passed.\n";
|
|
}
|
|
void world_layer_sync_checks() {
|
|
WorldLayersModel model;
|
|
model.choices.front() = "on";
|
|
const auto own = model.choices;
|
|
auto host_choices = default_world_layers();
|
|
host_choices.front() = "off";
|
|
host_choices.back() = "on";
|
|
WorldLayerOverride override;
|
|
check(override.apply(model, true, host_choices) && model.controlled_by_host &&
|
|
model.choices == host_choices, "Host layer override did not apply");
|
|
host_choices.front() = "default";
|
|
check(override.apply(model, true, host_choices) && override.local == own,
|
|
"Repeated host snapshot replaced local layer preferences");
|
|
auto invalid = host_choices;
|
|
invalid.back() = "invalid";
|
|
check(!override.apply(model, true, invalid) && model.choices == host_choices,
|
|
"Invalid layer override partially changed the world");
|
|
model.map = WorldMap::none; model.ready = false;
|
|
check(override.apply(model, false, {}) && !model.controlled_by_host && model.choices == own &&
|
|
!override.local, "Disabling sync during travel did not restore local choices");
|
|
check(unpack_world_layers(pack_world_layers(host_choices)) == host_choices,
|
|
"World layer modes did not round trip");
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(80);
|
|
auto &host = *sim.nodes[0]; auto &guest = *sim.nodes[1];
|
|
const auto roster = [&] {
|
|
auto p = packet(host, PacketKind::roster, sim.now);
|
|
p.capacity = host.capacity; p.force_world_layers = host.force_world_layers;
|
|
p.layers = pack_world_layers(host.layers);
|
|
p.members.push_back({p.source, host.epoch, "Host"});
|
|
for (const auto &peer : host.peers) if (peer.handshaken) p.members.push_back(peer.member);
|
|
// Everyone in one party led by the host, as a roster may list them.
|
|
for (auto &m : p.members) {
|
|
m.party = p.members.size() > 1 ? lobby_party : 0;
|
|
m.party_leader = m.party && m.id == p.source;
|
|
}
|
|
return p;
|
|
};
|
|
const auto stale = roster();
|
|
simulated_host_layers.choices = host_choices;
|
|
check(edit_world_layer_sync(guest, "on").starts_with("Only the session host") && !guest.force_world_layers,
|
|
"Guest could force world layer sync");
|
|
(void)edit_world_layer_sync(host, "on"); sim.run(5);
|
|
check(guest.force_world_layers && guest.layers == host_choices, "Host layers did not synchronize");
|
|
(void)edit_world_layer_sync(host, "invalid");
|
|
check(host.force_world_layers, "Invalid toggle changed world layer policy");
|
|
send_required(host, guest.transport.status().local_id, encode_wire(stale)); sim.run(4);
|
|
check(guest.force_world_layers && guest.layers == host_choices, "Stale roster changed layer policy");
|
|
simulated_host_layers.choices.front() = "off";
|
|
sim.run(4);
|
|
check(guest.layers == simulated_host_layers.choices, "Live layer changes did not propagate");
|
|
sim.add(); sim.run(70);
|
|
check(sim.nodes.back()->force_world_layers && sim.nodes.back()->layers == host.layers,
|
|
"Late joiner missed forced world layers");
|
|
auto bytes = encode(roster());
|
|
check(decode(bytes) && decode(bytes)->force_world_layers &&
|
|
unpack_world_layers(decode(bytes)->layers) == host.layers, "Roster lost world layer settings");
|
|
// The voice policy (allowed byte, 4-byte revision), voice range (2 bytes) and the guest
|
|
// noclip / No Bail byte follow the layer modes, then what a dedicated server adds, here
|
|
// none: polls (1 byte), custom votes (1) and an announcement (4).
|
|
const auto layers_end = bytes.size() - 5 - 2 - 1 - 6;
|
|
for (std::size_t n = layers_end - world_layers().size() - 1; n < bytes.size(); ++n)
|
|
check(!decode(std::span(bytes).first(n)), "Truncated world layer settings accepted");
|
|
auto malformed = bytes; malformed[layers_end - 1] = 3;
|
|
check(!decode(malformed), "Invalid layer mode accepted");
|
|
malformed = bytes; malformed[layers_end - world_layers().size() - 1] = 2;
|
|
check(!decode(malformed), "Invalid force-sync flag accepted");
|
|
auto forged = roster();
|
|
forged.source = sim.nodes[2]->transport.status().local_id; forged.epoch = sim.nodes[2]->epoch;
|
|
forged.force_world_layers = false;
|
|
check(send_packet(*sim.nodes[2], guest.transport.status().local_id, forged, true, false),
|
|
"World layer spoof fixture failed to send");
|
|
sim.run(4);
|
|
check(guest.force_world_layers, "Guest disabled host's forced world layers");
|
|
(void)edit_world_layer_sync(host, "off"); sim.run(5);
|
|
for (const auto &s : sim.nodes) check(!s->force_world_layers, "World layer unlock did not propagate");
|
|
check(edit_world_layer_sync(guest, "toggle").starts_with("Only the session host") &&
|
|
!guest.force_world_layers, "Guest toggled world layer sync");
|
|
for (const bool forced : {true, false, true, false}) {
|
|
(void)edit_world_layer_sync(host, "toggle"); sim.run(5);
|
|
for (const auto &s : sim.nodes)
|
|
check(s->force_world_layers == forced, "Repeated world-layer toggle reused a stale value");
|
|
}
|
|
(void)edit_world_layer_sync(host, "toggle");
|
|
(void)edit_world_layer_sync(host, "toggle");
|
|
check(!host.force_world_layers, "Back-to-back world-layer toggles did not use live state");
|
|
(void)edit_world_layer_sync(host, "on"); sim.run(4);
|
|
stop(guest, "Leave layer session");
|
|
check(!guest.force_world_layers, "Disconnected guest kept layer lock");
|
|
TravelSimulation travel(2);
|
|
auto &travelling_host = *travel.sim.nodes[0];
|
|
(void)edit_object_placement(travelling_host, "off");
|
|
(void)edit_world_layer_sync(travelling_host, "on"); travel.run(4);
|
|
begin_host_world(travelling_host, travel.city, travel.sim.now);
|
|
travel.ready[0] = false; travel.run(5);
|
|
simulated_host_layers.choices.back() = "off";
|
|
travel.current[0] = travel.city; travel.ready[0] = true; travel.run(8);
|
|
travel.finish(1); travel.run(12);
|
|
travel.connected();
|
|
check(travel.sim.nodes[1]->object_placement == ObjectPlacement::host_only && travel.sim.nodes[1]->force_world_layers &&
|
|
travel.sim.nodes[1]->layers == simulated_host_layers.choices,
|
|
"Map travel lost host controls or world layer changes");
|
|
simulated_host_layers = {};
|
|
std::cout << "World layers: live host sync, local preference restoration, late joins, stale/spoof rejection and bounded modes passed.\n";
|
|
}
|
|
void distance_settings_checks() {
|
|
Simulation sim;
|
|
sim.positions = {0, 25, 100, 250, 500, 750, 1000, 1250};
|
|
for (unsigned i = 0; i < 7; ++i) sim.add();
|
|
sim.run(80);
|
|
auto &host = *sim.nodes[0];
|
|
const auto host_roster = [&] {
|
|
auto p = packet(host, PacketKind::roster, sim.now);
|
|
p.distances = host.distances; p.capacity = host.capacity;
|
|
p.members.push_back({p.source, host.epoch, "Host"});
|
|
for (const auto &peer : host.peers) if (peer.handshaken) p.members.push_back(peer.member);
|
|
// Everyone in one party led by the host, as a roster may list them.
|
|
for (auto &m : p.members) {
|
|
m.party = p.members.size() > 1 ? lobby_party : 0;
|
|
m.party_leader = m.party && m.id == p.source;
|
|
}
|
|
return p;
|
|
};
|
|
auto stale = host_roster();
|
|
const auto message = edit_distances(host, "15 20 70 80");
|
|
const MultiplayerDistances custom{15, 20, 70, 80};
|
|
check(message.starts_with("TPS distances applied") && host.distances == custom, "Host edit did not apply");
|
|
sim.run(4);
|
|
for (const auto &s : sim.nodes) check(s->distances == custom, "Host edit did not synchronize to a guest");
|
|
auto *medium = find_peer(*sim.nodes[1], host.host_id);
|
|
auto *distant = find_peer(*sim.nodes[2], host.host_id);
|
|
const auto before_medium = medium->pose_count, before_distant = distant->pose_count;
|
|
sim.run(40);
|
|
check(medium->pose_count - before_medium >= 19 && medium->pose_count - before_medium <= 21 &&
|
|
distant->pose_count - before_distant >= 9 && distant->pose_count - before_distant <= 11,
|
|
"New policy did not change actual host delivery rates");
|
|
const auto nearby_id = sim.nodes[2]->transport.status().local_id;
|
|
auto *guest_stream = find_peer(*sim.nodes[1], nearby_id);
|
|
check(guest_stream->received_pose_interval == 100000, "New policy did not change guest-to-guest delivery");
|
|
for (const auto *invalid : {"", "10 20 30", "10 20 30 40 extra", "-1 20 30 40", "20 20 30 40",
|
|
"10 40 30 50", "10 20 40 40", "10 20 30 10001", "0 2147483648 30 40", "1.5 20 30 40"}) {
|
|
(void)edit_distances(host, invalid);
|
|
check(host.distances == custom, "Invalid host edit changed the policy");
|
|
}
|
|
check(edit_distances(*sim.nodes[1], "1 2 3 4").starts_with("Only the lobby host") &&
|
|
sim.nodes[1]->distances == custom, "Guest could edit its lobby policy");
|
|
send_required(host, sim.nodes[1]->transport.status().local_id, encode_wire(stale));
|
|
sim.run(4);
|
|
check(sim.nodes[1]->distances == custom, "Old reliable roster rolled back the policy");
|
|
sim.add(); sim.run(70);
|
|
check(sim.nodes.back()->distances == custom, "Late joiner missed host policy");
|
|
const auto fresh = host_roster();
|
|
const auto bytes = encode(fresh);
|
|
const auto decoded = decode(bytes);
|
|
check(decoded && decoded->distances == custom, "Distance policy lost values in the codec");
|
|
for (std::size_t n = bytes.size() - 8; n < bytes.size(); ++n)
|
|
check(!decode(std::span(bytes).first(n)), "Truncated roster policy accepted");
|
|
// The tick rate sits before placement, the forced flag, the layers and the voice policy.
|
|
auto malformed = bytes; malformed[bytes.size() - 5 - world_layers().size() - 3] = 255;
|
|
check(!decode(malformed), "Out-of-range wire policy accepted");
|
|
auto invalid_roster = fresh; invalid_roster.distances = {20, 10, 30, 40};
|
|
bool rejected{}; try { (void)encode(invalid_roster); } catch (...) { rejected = true; }
|
|
check(rejected, "Encoder accepted invalid distance order");
|
|
// An admitted direct peer still cannot publish the host's policy.
|
|
auto forged = fresh;
|
|
forged.source = sim.nodes[2]->transport.status().local_id; forged.epoch = sim.nodes[2]->epoch;
|
|
forged.distances = {1, 2, 3, 4};
|
|
check(send_packet(*sim.nodes[2], sim.nodes[1]->transport.status().local_id, forged, true, false),
|
|
"Spoof fixture did not reach a direct path");
|
|
sim.run(3);
|
|
check(sim.nodes[1]->mode == Mode::join && sim.nodes[1]->distances == custom,
|
|
"A direct guest replaced host distance settings");
|
|
// Increasing the radii must immediately clear previous low-rate hysteresis.
|
|
(void)edit_distances(host, "2000 2100 3000 3100"); sim.run(6);
|
|
for (const auto &s : sim.nodes) check(s->distances == host.distances, "Larger distances did not propagate");
|
|
check(find_peer(*sim.nodes[1], host.host_id)->received_pose_interval == 50000,
|
|
"Increased distances retained the previous slow band");
|
|
(void)edit_distances(host, "50 60 150 170"); sim.run(5);
|
|
check(host.distances == MultiplayerDistances{} && sim.nodes.back()->distances == MultiplayerDistances{},
|
|
"Default values did not restore across the lobby");
|
|
// Settings remain part of the room across native map teardown and late loads.
|
|
TravelSimulation travel(2);
|
|
auto &owner = *travel.sim.nodes[0];
|
|
(void)edit_distances(owner, "15 20 70 80"); travel.run(4);
|
|
begin_host_world(owner, travel.city, travel.sim.now);
|
|
travel.ready[0] = false; travel.run(5);
|
|
(void)edit_distances(owner, "30 40 90 110"); // Applied when gameplay is ready again.
|
|
travel.current[0] = travel.city; travel.ready[0] = true; travel.run(8);
|
|
travel.finish(1); travel.run(12);
|
|
travel.connected();
|
|
check(owner.distances == MultiplayerDistances{30, 40, 90, 110} &&
|
|
travel.sim.nodes[1]->distances == owner.distances, "Map travel lost updated host settings");
|
|
stop(*travel.sim.nodes[1], "Disconnect settings check");
|
|
check(travel.sim.nodes[1]->distances == MultiplayerDistances{}, "Disconnected guest retained the old lobby policy");
|
|
std::cout << "Host distance settings: eight-player sync, live host/direct rates, late joins, reset, stale/spoof rejection, invalid inputs, and map travel passed.\n";
|
|
}
|
|
void distance_mesh_checks() {
|
|
Simulation sim;
|
|
sim.positions = {0, 250, 251, 500, 750, 1000, 1250, 1500};
|
|
for (unsigned i = 0; i < 8; ++i) sim.add();
|
|
sim.run(90);
|
|
sim.fresh(8);
|
|
const auto measure = [&] {
|
|
std::array<std::array<std::uint64_t, 8>, 8> counts{};
|
|
for (unsigned i = 0; i < 8; ++i)
|
|
for (unsigned j = 0; j < 8; ++j)
|
|
if (i != j) counts[i][j] = find_peer(*sim.nodes[i], sim.nodes[j]->transport.status().local_id)->pose_count;
|
|
sim.run(80);
|
|
for (unsigned i = 0; i < 8; ++i)
|
|
for (unsigned j = 0; j < 8; ++j) {
|
|
if (i == j) continue;
|
|
auto *peer = find_peer(*sim.nodes[i], sim.nodes[j]->transport.status().local_id);
|
|
const bool nearby = i == 0 || std::abs(sim.positions[i] - sim.positions[j]) < 50;
|
|
const auto received = peer->pose_count - counts[i][j];
|
|
check(received >= (nearby ? 78U : 18U) && received <= (nearby ? 82U : 22U),
|
|
"Distance policy rate differs on direct/forwarded/upstream path");
|
|
}
|
|
sim.fresh(8);
|
|
};
|
|
measure();
|
|
const auto a = sim.nodes[1]->transport.status().local_id, b = sim.nodes[2]->transport.status().local_id;
|
|
SimulatedNetwork::blocked.insert(SimulatedNetwork::pair(a, b));
|
|
sim.nodes[1]->transport.disconnect(b, "Force nearby relay with distant host");
|
|
sim.run(80);
|
|
measure();
|
|
SimulatedNetwork::blocked.clear();
|
|
sim.positions[2] = 350; // The nearby pair moves to the 10 TPS band.
|
|
sim.run(90);
|
|
auto *peer = find_peer(*sim.nodes[1], b); const auto before = peer->pose_count;
|
|
sim.run(80);
|
|
check(peer->pose_count - before >= 38 && peer->pose_count - before <= 42, "Middle band is not 10 TPS");
|
|
sim.positions[2] = 251;
|
|
sim.run(90);
|
|
measure();
|
|
SimulatedNetwork::lose_unreliable = true;
|
|
sim.run(80);
|
|
sim.fresh(8);
|
|
SimulatedNetwork::lose_unreliable = false;
|
|
std::cout << "Eight-player distance delivery: 20/10/5 TPS, full upstream, nearby relay through a distant host, approach recovery and loss passed.\n";
|
|
}
|
|
void mesh_checks() {
|
|
Packet report;
|
|
report.kind = PacketKind::routes;
|
|
report.session = 1;
|
|
report.epoch = 2;
|
|
report.map = 42;
|
|
report.members = {{76561198000000321ULL, 9, {}}};
|
|
const auto encoded = encode(report);
|
|
check(decode(encoded)->members == report.members, "Route report codec changed membership");
|
|
for (std::size_t n = 0; n < encoded.size(); ++n)
|
|
check(!decode(std::span(encoded.data(), n)), "Truncated route report accepted");
|
|
auto duplicate = report;
|
|
duplicate.members.push_back(report.members.front());
|
|
check(!valid_routes(duplicate.members), "Duplicate route identity accepted");
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 5; ++i) {
|
|
sim.add();
|
|
sim.run(70);
|
|
sim.fresh(i + 1);
|
|
}
|
|
auto &host = *sim.nodes.front();
|
|
auto bytes = host.transport.status().sent_bytes;
|
|
sim.run(40);
|
|
sim.fresh(5);
|
|
const auto direct_bytes = host.transport.status().sent_bytes - bytes;
|
|
for (std::size_t i = 1; i < 5; ++i)
|
|
for (std::size_t j = 1; j < 5; ++j)
|
|
if (i != j)
|
|
check(find_peer(*sim.nodes[i], sim.nodes[j]->transport.status().local_id)->direct_ready,
|
|
"Guest pair failed to establish direct Steam route");
|
|
// Expiring reports must restore every relay stream even while physical links remain up.
|
|
SimulatedNetwork::lose_reports = true;
|
|
sim.run(40);
|
|
bytes = host.transport.status().sent_bytes;
|
|
sim.run(40);
|
|
const auto relay_bytes = host.transport.status().sent_bytes - bytes;
|
|
sim.fresh(5);
|
|
check(direct_bytes * 2 < relay_bytes, "Host upload did not fall substantially with direct routes");
|
|
SimulatedNetwork::lose_reports = false;
|
|
std::cout << "Five players: host bytes over two seconds, direct=" << direct_bytes
|
|
<< ", forced relay=" << relay_bytes << "\n";
|
|
const auto a = sim.nodes[1]->transport.status().local_id, b = sim.nodes[2]->transport.status().local_id;
|
|
SimulatedNetwork::blocked.insert(SimulatedNetwork::pair(a, b));
|
|
sim.nodes[1]->transport.disconnect(b, "Simulated route loss");
|
|
sim.run(50);
|
|
sim.fresh(5);
|
|
check(!find_peer(*sim.nodes[1], b)->direct_ready, "Broken direct route still active");
|
|
const auto *destination = find_peer(host, a);
|
|
const auto *source = find_peer(host, b);
|
|
check(needs_relay(destination->direct_routes, destination->route_reported, source->member, sim.now),
|
|
"Lost route did not restore host forwarding");
|
|
SimulatedNetwork::blocked.clear();
|
|
sim.run(90);
|
|
sim.fresh(5);
|
|
check(find_peer(*sim.nodes[1], b)->direct_ready, "Direct path did not recover");
|
|
SimulatedNetwork::lose_unreliable = true;
|
|
sim.run(100);
|
|
sim.fresh(5);
|
|
SimulatedNetwork::lose_unreliable = false;
|
|
// Late joins up to the supported maximum.
|
|
for (unsigned i = 5; i < 8; ++i) {
|
|
sim.add();
|
|
sim.run(70);
|
|
sim.fresh(i + 1);
|
|
}
|
|
// A direct guest cannot impersonate the host or another roster member.
|
|
auto forged = packet(*sim.nodes[2], PacketKind::pose, sim.now);
|
|
forged.source = host.host_id;
|
|
send_packet(*sim.nodes[2], sim.nodes[1]->transport.status().local_id, forged, false, false);
|
|
sim.run(70);
|
|
sim.fresh(8);
|
|
check(sim.nodes[1]->mode == Mode::join, "Invalid direct packet ended the host session");
|
|
// A limited-upload guest reduces optional fan-out; receivers restore relay.
|
|
const auto limited_id = sim.nodes[1]->transport.status().local_id;
|
|
SimulatedNetwork::queues[limited_id] = 90000;
|
|
sim.run(180);
|
|
sim.fresh(8);
|
|
check(sim.nodes[1]->direct_upload.limit == 0, "Congested guest did not reduce direct uploads");
|
|
const auto *limited = find_peer(host, limited_id);
|
|
for (unsigned i = 2; i < 8; ++i) {
|
|
const auto *target = find_peer(host, sim.nodes[i]->transport.status().local_id);
|
|
check(needs_relay(target->direct_routes, target->route_reported, limited->member, sim.now),
|
|
"Paused direct uploads did not restore relay");
|
|
}
|
|
SimulatedNetwork::queues.clear();
|
|
sim.run(900);
|
|
sim.fresh(8);
|
|
check(sim.nodes[1]->direct_upload.limit >= 6 && sim.nodes[1]->direct_upload.limit < max_remote_players - 1,
|
|
"Direct uploads did not recover gradually");
|
|
// A guest reconnect uses a new epoch; old direct routes cannot suppress its data.
|
|
auto &guest = *sim.nodes[3];
|
|
const auto id = guest.transport.status().local_id;
|
|
stop(guest, "Test departure");
|
|
sim.run(10);
|
|
check(!find_peer(host, id), "Departed player retained by host");
|
|
guest.mode = Mode::join;
|
|
guest.started_map = true; // The simulation reconnects while its local map remains loaded.
|
|
guest.secret = 73;
|
|
guest.epoch = 900;
|
|
guest.map = 42;
|
|
guest.host_id = host.host_id;
|
|
guest.password = password_key("test room", 73);
|
|
guest.transport.join(host.host_id);
|
|
auto cosmetics = packet(guest, PacketKind::cosmetics, sim.now);
|
|
cosmetics.appearance = {{skater_recipe_key, 2, {}, {{1, "Rejoined outfit", {}}}},
|
|
{board_recipe_key, 1, {}, {{2, "Deck", {}}}}};
|
|
guest.cosmetic_packet = encode_wire(cosmetics);
|
|
sim.run(100);
|
|
sim.fresh(8);
|
|
// Password admission is still exclusively controlled by the host.
|
|
stop(*sim.nodes.back(), "Make room");
|
|
sim.run(10);
|
|
auto &wrong = sim.add(false);
|
|
sim.run(30);
|
|
check(wrong.mode == Mode::off, "Wrong password entered mesh");
|
|
SteamTransport outsider;
|
|
outsider.open();
|
|
const Member victim{sim.nodes[1]->transport.status().local_id, 101, {}};
|
|
outsider.allow_peers(std::span(&victim, 1));
|
|
check(!outsider.connect_peer(victim.id), "Unknown identity connected to an admitted guest");
|
|
stop(host, "Host departure");
|
|
sim.run(5);
|
|
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";
|
|
}
|
|
// Typing dots: the host relays an on notice, an off notice clears it, and a missed off expires.
|
|
void typing_checks() {
|
|
Simulation sim;
|
|
for (unsigned i = 0; i < 3; ++i) sim.add();
|
|
sim.run(60); sim.fresh(3);
|
|
auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2];
|
|
const auto id = [](const Session &s) { return s.transport.status().local_id; };
|
|
const auto until = [](Session &s, std::uint64_t who) {
|
|
const auto *peer = find_peer(s, who);
|
|
return peer ? peer->typing_until : 0;
|
|
};
|
|
send_typing(first, true);
|
|
sim.run(4);
|
|
check(until(host, id(first)) > sim.now && until(second, id(first)) > sim.now && first.local_typing_until != 0,
|
|
"A guest's typing notice did not reach the host and the other guest");
|
|
check(until(first, id(host)) == 0 && second.local_typing_until == 0, "A typing notice was applied to the wrong player");
|
|
check(std::none_of(host.chat.begin(), host.chat.end(), [](const auto &line) { return line.text == "." || line.text == "..."; }) &&
|
|
std::none_of(second.chat.begin(), second.chat.end(), [](const auto &line) { return line.text == "." || line.text == "..."; }),
|
|
"A typing notice was written into chat");
|
|
send_typing(first, false);
|
|
sim.run(4);
|
|
check(until(host, id(first)) == 0 && until(second, id(first)) == 0 && first.local_typing_until == 0,
|
|
"Stopping typing left the dots up");
|
|
send_typing(host, true);
|
|
sim.run(4);
|
|
check(until(first, id(host)) > sim.now && until(second, id(host)) > sim.now && host.local_typing_until != 0,
|
|
"The host's typing notice did not reach both guests");
|
|
send_typing(host, false);
|
|
sim.run(2);
|
|
check(until(first, id(host)) == 0 && until(second, id(host)) == 0 && host.local_typing_until == 0,
|
|
"The host stopping left the dots up");
|
|
send_typing(first, true);
|
|
sim.run(4);
|
|
const auto held = until(second, id(first));
|
|
check(held > sim.now, "Typing did not start again");
|
|
sim.run(static_cast<unsigned>(typing_hold_us / network_tick_us) + 8);
|
|
check(until(second, id(first)) < sim.now && until(host, id(first)) < sim.now && until(second, id(first)) != 0,
|
|
"A missed stop notice left the dots up, or cleared the deadline");
|
|
std::cout << "Typing notices: relay, stop and expiry passed.\n";
|
|
}
|
|
} // namespace
|
|
} // namespace dingosdk::multiplayer
|
|
int main(int argc, char **argv) {
|
|
try {
|
|
// A small stand-in for the catalog read from the level data at startup.
|
|
dingosdk::install_world_layer_catalog({
|
|
{{"Levels/Game/BAM_LevelRoot/Root", dingosdk::WorldMap::bam, -1, true},
|
|
{"Levels/Game/BAM_LevelRoot/TOD_1_Morning", dingosdk::WorldMap::bam, 0, false},
|
|
{"Levels/Game/BAM_LevelRoot/Season_01", dingosdk::WorldMap::bam, 0, false}},
|
|
{0},
|
|
{{"bam_root", "Root", "", dingosdk::WorldMap::bam, 0, 0, "World"},
|
|
{"bam_tod_1_morning", "TOD 1 Morning", "", dingosdk::WorldMap::bam, 1, 1, "Lighting"},
|
|
{"bam_season_01", "Season 01", "", dingosdk::WorldMap::bam, 2, 2, "Seasonal"}}});
|
|
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;
|
|
}
|
|
if (argc == 2 && std::string_view(argv[1]) == "--session-controls-only") {
|
|
dingosdk::multiplayer::session_controls_checks();
|
|
dingosdk::multiplayer::guest_building_checks();
|
|
dingosdk::multiplayer::world_layer_sync_checks(); return 0;
|
|
}
|
|
if (argc == 2 && std::string_view(argv[1]) == "--join-tick-only") return 0;
|
|
if (argc == 2 && std::string_view(argv[1]) == "--pacing-only") {
|
|
dingosdk::multiplayer::pacing_checks();
|
|
dingosdk::multiplayer::client_tick_checks(); return 0;
|
|
}
|
|
if (argc == 2 && std::string_view(argv[1]) == "--priorities-only") {
|
|
dingosdk::multiplayer::priority_mesh_checks();
|
|
dingosdk::multiplayer::map_checks();
|
|
dingosdk::multiplayer::travel_checks();
|
|
return 0;
|
|
}
|
|
if (argc == 2 && std::string_view(argv[1]) == "--distance-settings-only") {
|
|
dingosdk::multiplayer::distance_settings_checks(); return 0;
|
|
}
|
|
if (argc == 2 && std::string_view(argv[1]) == "--bandwidth-only") {
|
|
dingosdk::multiplayer::distance_mesh_checks(); return 0;
|
|
}
|
|
dingosdk::multiplayer::map_checks();
|
|
if (argc == 2 && std::string_view(argv[1]) == "--map-only") return 0;
|
|
dingosdk::multiplayer::travel_checks();
|
|
if (argc == 2 && std::string_view(argv[1]) == "--travel-only") return 0;
|
|
dingosdk::multiplayer::mesh_checks();
|
|
dingosdk::multiplayer::throwdown_routing_checks();
|
|
dingosdk::multiplayer::typing_checks();
|
|
dingosdk::multiplayer::party_checks();
|
|
dingosdk::multiplayer::role_checks();
|
|
dingosdk::multiplayer::global_ban_checks();
|
|
dingosdk::multiplayer::physics_extras_checks();
|
|
dingosdk::multiplayer::stall_checks();
|
|
dingosdk::multiplayer::scoring_checks();
|
|
dingosdk::multiplayer::object_sync_checks();
|
|
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;
|
|
} catch (const std::exception &e) {
|
|
std::cerr << e.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|