mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 02:47:01 +02:00
Two fixes taken from worthlessnorms' pull request 16. The bug it was
opened for, rewritten copies read back from the mod's folder, was fixed
separately in 6fd3845.
Mod merge (Engine/Vfs/mod_merge_cas_store.cpp, mod_merge_combine.cpp)
- To combine two mods' edits the merge reads each mod's copy from the
mod's own folder, which means turning its placement in the merged
patch back into the mod's archive and offset. It took the first of the
mod's blocks in that patch archive. A mod added while the game runs
has all its archives appended to the patch's archive 1, so a copy in
its second archive was looked for in its first, the read failed, and
the asset kept the highest-priority copy.
- unshift() picks the block the offset falls in. Of two blocks that
start at the same byte the later one is used: the earlier is an empty
archive.
- Reproduced with a cosmetic mod rebuilt as two archives and added to a
running merge: cas_main_bundlereftable "could not be combined (Cannot
read ...\cas_01.cas)" before, "combined 2 edits (101 preset(s))"
after. A launch merge gives every archive an index of its own and was
not affected; 40 installed mods merge exactly as before.
- mod_merge_unshift_tests covers an archive with its own index, three
appended blocks, bytes before the first block, and an empty archive.
Tests (CMakeLists.txt, Extension/Multiplayer/Test/mesh_tests.cpp)
- multiplayer_lobbies did not link: steam_lobbies.cpp calls
contains_bad_words and the target did not link dingosdk_word_filter.
- multiplayer_mesh did not link: four functions the session code calls
had no stub. Two are the ones the pull request adds
(update_developer_hoodie, remote_skater_generation); the developer
board has since added update_developer_board and remote_board_entity.
- Both targets build and their tests pass.
Co-authored-by: worthlessnorms <110003169+worthlessnorms@users.noreply.github.com>
1809 lines
92 KiB
C++
1809 lines
92 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 <iostream>
|
|
#include <set>
|
|
|
|
namespace dingosdk {
|
|
bool read_local_camera_transform(std::uintptr_t, std::uintptr_t, std::array<float, 16>&) noexcept { return false; }
|
|
ParksModel local_profile_parks() { return {}; }
|
|
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 &) {}
|
|
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_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; }
|
|
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 &) noexcept {}
|
|
void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &) noexcept {}
|
|
}
|
|
namespace dingosdk::multiplayer {
|
|
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 {}
|
|
std::optional<bool> local_preference(std::string_view) noexcept { return {}; }
|
|
void set_local_preference(std::string_view, bool) noexcept {}
|
|
std::optional<Json> local_value(std::string_view) noexcept { return {}; }
|
|
void set_local_values(const std::vector<std::pair<std::string, Json>>&) noexcept {}
|
|
}
|
|
|
|
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;
|
|
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) {
|
|
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()}}, 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; }
|
|
|
|
// 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 {}
|
|
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>>) 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");
|
|
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");
|
|
}
|
|
}
|
|
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;
|
|
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;
|
|
for (std::size_t n = 0; n < nodes.size(); ++n) {
|
|
auto &s = *nodes[n];
|
|
if (s.mode == Mode::off)
|
|
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);
|
|
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 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");
|
|
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");
|
|
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::failed;
|
|
}
|
|
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);
|
|
check(pair.guest.mode == Mode::off && pair.guest.status == "Map not installed.",
|
|
"Missing map did not end the pending join with its error");
|
|
}
|
|
{
|
|
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);
|
|
// The lobby party, as the host's own roster sends it.
|
|
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);
|
|
// The lobby party, as the host's own roster sends it.
|
|
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.
|
|
const auto layers_end = bytes.size() - 5 - 2 - 1;
|
|
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);
|
|
// The lobby party, as the host's own roster sends it.
|
|
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");
|
|
}
|
|
} // 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]) == "--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::scoring_checks();
|
|
dingosdk::multiplayer::object_sync_checks();
|
|
dingosdk::multiplayer::session_controls_checks();
|
|
dingosdk::multiplayer::guest_building_checks();
|
|
dingosdk::multiplayer::world_layer_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;
|
|
}
|
|
}
|