From 3f62fd5927715a9cf57df98059c46d8a1be104b7 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Sun, 6 Sep 2026 18:08:06 +0000 Subject: [PATCH] multiplayer stage 3: crew model, claim handshake and AI handover A vehicle is not owned by one player: two people share a cab, so the session needs a crew relation rather than an owner field. The server keeps it and is the only one allowed to change it. - network/session: vehicle_crew { entity_id, crew, ai_before_claim } and a crew_registry holding the roster. the host goes through exactly the same path as a remote peer (claim -> crew -> grant), only without transport - CLAIM_VEHICLE / CLAIM_GRANTED / CLAIM_DENIED / LEAVE_VEHICLE / CREW_UPDATE; the server validates the vehicle exists, is present in the scenario and has room in the crew, and answers a refusal with a reason the lobby shows - the cab of a claimed vehicle is created by the authority posting the ordinary replicated entervehicle, so every peer builds it and the vehicle is never moved. the claiming peer only walks in once the cab appears locally - AI transitions follow the crew size: 0 -> 1 remembers whether an AI was really driving and takes the vehicle over, 1 -> 2 and 2 -> 1 change nothing, 1 -> 0 hands the vehicle back but only if there was an AI driver to begin with. driver mode no longer fires aidriverdisable of its own in multiplayer - losing a connection removes that peer from every crew it was on; a vehicle with somebody still on board carries on untouched - the lobby lists who is on board, offers "join crew" for a vehicle another player already occupies, and lets a player step off The AI and cab handling runs as a reconciliation pass rather than as edge triggered actions, so a request that has not taken effect yet (the cab needs a frame to appear) is simply repeated, with a cooldown to keep it off the wire. --- CMakeLists.txt | 1 + application/application.cpp | 12 ++ application/application.h | 5 + application/drivermode.cpp | 27 +++- network/entities.cpp | 5 +- network/manager.cpp | 70 ++++++++- network/manager.h | 11 ++ network/message.cpp | 71 +++++++++ network/message.h | 62 ++++++++ network/network.cpp | 89 +++++++++++- network/network.h | 8 + network/session.cpp | 266 ++++++++++++++++++++++++++++++++++ network/session.h | 90 ++++++++++++ utilities/Globals.h | 4 + widgets/multiplayer_lobby.cpp | 84 +++++++---- widgets/multiplayer_lobby.h | 7 +- 16 files changed, 769 insertions(+), 43 deletions(-) create mode 100644 network/session.cpp create mode 100644 network/session.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b223f699..d128ed2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,6 +194,7 @@ set(SOURCES "network/message.cpp" "network/manager.cpp" "network/entities.cpp" +"network/session.cpp" "network/backend/asio.cpp" "widgets/vehiclelist.cpp" diff --git a/application/application.cpp b/application/application.cpp index 714a02bc..0c4768e8 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -460,6 +460,18 @@ bool eu07_application::is_client() const return m_network && m_network->client; } +void eu07_application::request_vehicle_claim(network::NetworkEntityId const Entity) +{ + if (m_network) + m_network->request_claim(Entity); +} + +void eu07_application::request_vehicle_leave(network::NetworkEntityId const Entity) +{ + if (m_network) + m_network->request_leave(Entity); +} + int eu07_application::run() { auto frame{0}; diff --git a/application/application.h b/application/application.h index 11415603..99cc6565 100644 --- a/application/application.h +++ b/application/application.h @@ -93,6 +93,11 @@ public: is_server() const; bool is_client() const; + // asks the session authority to put us on the crew of given vehicle, or take us off it + void + request_vehicle_claim( network::NetworkEntityId const Entity ); + void + request_vehicle_leave( network::NetworkEntityId const Entity ); private: // types diff --git a/application/drivermode.cpp b/application/drivermode.cpp index 1ca75c37..e41eb077 100644 --- a/application/drivermode.cpp +++ b/application/drivermode.cpp @@ -266,6 +266,22 @@ bool driver_mode::update() // variable step simulation time routines + if (Global.network_leave_pending) + { + // we were taken off the crew (by our own request or by the server), so the + // camera goes back to observing. the cab instance itself stays put, it may + // still be occupied by somebody else or handed back to the AI + Global.network_leave_pending = false; + if (simulation::Train != nullptr) + { + if (!FreeFlyModeFlag) + InOutKey(); + simulation::Train = nullptr; + Camera.m_owner = nullptr; + Global.local_start_vehicle = "ghostview"; + } + } + if (change_train.empty() && !Global.network_pending_vehicle.empty()) { // vehicle picked in the multiplayer lobby; the cab itself is built by the @@ -282,8 +298,17 @@ bool driver_mode::update() Global.local_start_vehicle = change_train; simulation::Train = train; InOutKey(); - m_relay.post(user_command::aidriverdisable, 0.0, 0.0, GLFW_PRESS, 0); + if (!Application.is_server() && !Application.is_client()) + { + // in a multiplayer session the AI handover belongs to the server, which + // also knows whether the vehicle had an AI driver in the first place + m_relay.post(user_command::aidriverdisable, 0.0, 0.0, GLFW_PRESS, 0); + } change_train.clear(); + + auto ui = std::dynamic_pointer_cast(m_userinterface); + if (ui != nullptr) + ui->show_multiplayer_lobby(false); } } diff --git a/network/entities.cpp b/network/entities.cpp index 3bdd08af..d8c78b74 100644 --- a/network/entities.cpp +++ b/network/entities.cpp @@ -9,6 +9,7 @@ http://mozilla.org/MPL/2.0/. #include "stdafx.h" #include "network/entities.h" +#include "network/session.h" #include "simulation/simulation.h" #include "vehicle/DynObj.h" @@ -105,9 +106,7 @@ void entity_registry::refresh() TDynamicObject const *vehicle = resolve(entry.id); entry.ai_active = (vehicle != nullptr && vehicle->Mechanik != nullptr && vehicle->Mechanik->AIControllFlag); - // until the crew model of stage 3 is in place, an existing cab instance is the - // only evidence that somebody took the vehicle over - entry.crew_count = (simulation::Trains.find(entry.name) != nullptr ? 1 : 0); + entry.crew_count = Crews.count(entry.id); entry.crew_capacity = CREW_CAPACITY; entry.claimable = (vehicle != nullptr && entry.crew_count < entry.crew_capacity); } diff --git a/network/manager.cpp b/network/manager.cpp index 37900d14..6a64f97b 100644 --- a/network/manager.cpp +++ b/network/manager.cpp @@ -11,6 +11,7 @@ http://mozilla.org/MPL/2.0/. #include "network/manager.h" #include "simulation/simulation.h" #include "utilities/Logs.h" +#include "utilities/Globals.h" network::server_manager::server_manager() { @@ -82,13 +83,80 @@ void network::server_manager::publish_vehicle_list() srv->push_message(msg); } +void network::server_manager::update_crews() +{ + Crews.reconcile(); + + if (--crew_publish_countdown <= 0) { + // peers that joined after somebody took a vehicle over have to learn about it too + crew_publish_countdown = PUBLISH_INTERVAL_FRAMES; + Crews.mark_all_pending(); + } + + auto const changed = Crews.take_pending_updates(); + for (NetworkEntityId const id : changed) { + crew_update msg; + msg.entity_id = id; + msg.crew = Crews.crew_of(id); + + for (auto srv : servers) + srv->push_message(msg); + } +} + +void network::server_manager::apply_local_claim(NetworkEntityId entity_id) +{ + auto const result = Crews.claim(PEER_HOST, entity_id); + + if (result == claim_result::granted || result == claim_result::already_member) { + Global.network_lobby_message.clear(); + Global.network_pending_vehicle = Entities.name_of(entity_id); + } + else { + WriteLog("net: refused local claim of vehicle " + std::to_string(entity_id) + ": " + describe(result), logtype::net); + Global.network_lobby_message = describe(result); + } +} + +void network::server_manager::apply_local_leave(NetworkEntityId entity_id) +{ + if (Crews.leave(PEER_HOST, entity_id)) + Global.network_leave_pending = true; +} + +void network::manager::request_claim(NetworkEntityId entity_id) +{ + if (client) { + client->send_claim(entity_id); + return; + } + + if (servers) + servers->apply_local_claim(entity_id); +} + +void network::manager::request_leave(NetworkEntityId entity_id) +{ + if (client) { + client->send_leave(entity_id); + // the server owns the roster, but stepping out of our own cab is a local matter + Global.network_leave_pending = true; + return; + } + + if (servers) + servers->apply_local_leave(entity_id); +} + void network::manager::update() { for (auto &backend : backend_list()) backend.second->update(); - if (servers && simulation::is_ready) + if (servers && simulation::is_ready) { + servers->update_crews(); servers->publish_vehicle_list(); + } if (client) client->update(); diff --git a/network/manager.h b/network/manager.h index e5aa01ea..a530fa64 100644 --- a/network/manager.h +++ b/network/manager.h @@ -19,6 +19,11 @@ namespace network void create_server(const std::string &backend, const std::string &conf); // refreshes the authoritative vehicle roster and publishes it when it changed void publish_vehicle_list(); + // brings the world in line with the crew roster and announces every change + void update_crews(); + // crew request coming from the local participant, which needs no transport + void apply_local_claim(NetworkEntityId entity_id); + void apply_local_leave(NetworkEntityId entity_id); private: // how many frames may pass between two unconditional broadcasts of the roster @@ -26,6 +31,7 @@ namespace network std::vector last_published_list; int publish_countdown = 0; + int crew_publish_countdown = 0; }; class manager @@ -39,5 +45,10 @@ namespace network void create_server(const std::string &backend, const std::string &conf); void connect(const std::string &backend, const std::string &conf); void update(); + + // routes a lobby request to whoever holds the authority: straight into the crew + // registry when we are the server, over the wire when we are a client + void request_claim(NetworkEntityId entity_id); + void request_leave(NetworkEntityId entity_id); }; } diff --git a/network/message.cpp b/network/message.cpp index 801c8423..3a5b0eea 100644 --- a/network/message.cpp +++ b/network/message.cpp @@ -80,6 +80,67 @@ void network::vehicle_list::deserialize(std::istream &stream) } } +void network::claim_vehicle::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, entity_id); +} + +void network::claim_vehicle::deserialize(std::istream &stream) +{ + entity_id = sn_utils::ld_uint32(stream); +} + +void network::claim_granted::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, entity_id); +} + +void network::claim_granted::deserialize(std::istream &stream) +{ + entity_id = sn_utils::ld_uint32(stream); +} + +void network::claim_denied::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, entity_id); + sn_utils::s_str(stream, reason); +} + +void network::claim_denied::deserialize(std::istream &stream) +{ + entity_id = sn_utils::ld_uint32(stream); + reason = sn_utils::d_str(stream); +} + +void network::leave_vehicle::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, entity_id); +} + +void network::leave_vehicle::deserialize(std::istream &stream) +{ + entity_id = sn_utils::ld_uint32(stream); +} + +void network::crew_update::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, entity_id); + sn_utils::ls_uint32(stream, (uint32_t)crew.size()); + for (PeerId const peer : crew) + sn_utils::ls_uint32(stream, peer); +} + +void network::crew_update::deserialize(std::istream &stream) +{ + entity_id = sn_utils::ld_uint32(stream); + + crew.clear(); + uint32_t const count = sn_utils::ld_uint32(stream); + crew.reserve(count); + for (uint32_t i = 0; i < count; i++) + crew.emplace_back(sn_utils::ld_uint32(stream)); +} + void ::network::request_command::serialize(std::ostream &stream) const { sn_utils::ls_uint32(stream, commands.size()); @@ -169,6 +230,16 @@ std::shared_ptr network::deserialize_message(std::istream &str msg = std::make_shared(); else if (type == message::VEHICLE_LIST) msg = std::make_shared(); + else if (type == message::CLAIM_VEHICLE) + msg = std::make_shared(); + else if (type == message::CLAIM_GRANTED) + msg = std::make_shared(); + else if (type == message::CLAIM_DENIED) + msg = std::make_shared(); + else if (type == message::LEAVE_VEHICLE) + msg = std::make_shared(); + else if (type == message::CREW_UPDATE) + msg = std::make_shared(); if (!msg) { // unknown message type; hand back a marker the peer handlers treat as a protocol error diff --git a/network/message.h b/network/message.h index 42d62ebb..61602162 100644 --- a/network/message.h +++ b/network/message.h @@ -16,6 +16,11 @@ struct message REQUEST_COMMAND, SERVER_REJECT, VEHICLE_LIST, + CLAIM_VEHICLE, + CLAIM_GRANTED, + CLAIM_DENIED, + LEAVE_VEHICLE, + CREW_UPDATE, TYPE_MAX }; @@ -80,6 +85,63 @@ struct vehicle_list : public message virtual void deserialize(std::istream &stream) override; }; +// a peer asking the server to be put on the crew of a vehicle +struct claim_vehicle : public message +{ + claim_vehicle() : message(CLAIM_VEHICLE) {} + + uint32_t entity_id{ ENTITY_NONE }; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; +}; + +// answer to a claim the server accepted +struct claim_granted : public message +{ + claim_granted() : message(CLAIM_GRANTED) {} + + uint32_t entity_id{ ENTITY_NONE }; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; +}; + +// answer to a claim the server refused, with a reason the lobby can show +struct claim_denied : public message +{ + claim_denied() : message(CLAIM_DENIED) {} + + uint32_t entity_id{ ENTITY_NONE }; + std::string reason; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; +}; + +// a peer stepping off the crew of a vehicle +struct leave_vehicle : public message +{ + leave_vehicle() : message(LEAVE_VEHICLE) {} + + uint32_t entity_id{ ENTITY_NONE }; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; +}; + +// authoritative crew of one vehicle, broadcast whenever it changes +struct crew_update : public message +{ + crew_update() : message(CREW_UPDATE) {} + + uint32_t entity_id{ ENTITY_NONE }; + std::vector crew; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; +}; + struct request_command : public message { request_command(type_e type) : message(type) {} diff --git a/network/network.cpp b/network/network.cpp index 7764ff99..608bec54 100644 --- a/network/network.cpp +++ b/network/network.cpp @@ -97,23 +97,37 @@ network::server::server(std::shared_ptr buf) : backbuffer(buf) } -void network::server::push_delta(const frame_info &msg) +void network::server::prune_clients() { for (auto it = clients.begin(); it != clients.end(); ) { if ((*it)->state == connection::DEAD) { + if ((*it)->peer_id != PEER_NONE) { + WriteLog("net: peer " + std::to_string((*it)->peer_id) + " disconnected", logtype::net); + // a vehicle keeps running for whoever is left on board; it only falls back + // to the AI when the crew becomes empty, which Crews decides on its own + Crews.drop_peer((*it)->peer_id); + } it = clients.erase(it); continue; } - - if ((*it)->state == connection::ACTIVE) - (*it)->send_message(msg); - it++; } } +void network::server::push_delta(const frame_info &msg) +{ + prune_clients(); + + for (auto const &client : clients) { + if (client->state == connection::ACTIVE) + client->send_message(msg); + } +} + void network::server::push_message(const message &msg) { + prune_clients(); + for (auto const &client : clients) { if (client->state == connection::ACTIVE) client->send_message(msg); @@ -177,6 +191,29 @@ void network::server::handle_message(std::shared_ptr conn, const mes WriteLog("net: peer " + std::to_string(conn->peer_id) + " connected, build \"" + cmd.app_version + "\", scenario \"" + Global.SceneryFile + "\"", logtype::net); } + else if (msg.type == message::CLAIM_VEHICLE) { + const auto& cmd = dynamic_cast(msg); + + auto const result = Crews.claim(conn->peer_id, cmd.entity_id); + if (result == claim_result::granted || result == claim_result::already_member) { + claim_granted reply; + reply.entity_id = cmd.entity_id; + conn->send_message(reply); + } + else { + WriteLog("net: refused claim of vehicle " + std::to_string(cmd.entity_id) + " by peer " + + std::to_string(conn->peer_id) + ": " + describe(result), logtype::net); + + claim_denied reply; + reply.entity_id = cmd.entity_id; + reply.reason = describe(result); + conn->send_message(reply); + } + } + else if (msg.type == message::LEAVE_VEHICLE) { + const auto& cmd = dynamic_cast(msg); + Crews.leave(conn->peer_id, cmd.entity_id); + } else if (msg.type == message::REQUEST_COMMAND) { const auto& cmd = dynamic_cast(msg); @@ -267,6 +304,26 @@ void network::client::send_commands(command_queue::commands_map commands) conn->send_message(msg); } +void network::client::send_claim(NetworkEntityId entity_id) +{ + if (!conn || conn->state != connection::ACTIVE) + return; + + claim_vehicle msg; + msg.entity_id = entity_id; + conn->send_message(msg); +} + +void network::client::send_leave(NetworkEntityId entity_id) +{ + if (!conn || conn->state != connection::ACTIVE) + return; + + leave_vehicle msg; + msg.entity_id = entity_id; + conn->send_message(msg); +} + void network::client::handle_message(std::shared_ptr conn, const message &msg) { if (msg.type >= message::TYPE_MAX) @@ -322,6 +379,28 @@ void network::client::handle_message(std::shared_ptr conn, const mes Entities.adopt(cmd.vehicles); } + if (msg.type == message::CREW_UPDATE) { + const auto& cmd = dynamic_cast(msg); + Crews.mirror(cmd.entity_id, cmd.crew); + } + + if (msg.type == message::CLAIM_GRANTED) { + const auto& cmd = dynamic_cast(msg); + + WriteLog("net: claim of vehicle " + std::to_string(cmd.entity_id) + " granted", logtype::net); + Global.network_lobby_message.clear(); + // the cab itself is built by the replicated entervehicle the server posts; + // we only have to walk into it once it shows up + Global.network_pending_vehicle = Entities.name_of(cmd.entity_id); + } + + if (msg.type == message::CLAIM_DENIED) { + const auto& cmd = dynamic_cast(msg); + + WriteLog("net: claim of vehicle " + std::to_string(cmd.entity_id) + " denied: " + cmd.reason, logtype::net); + Global.network_lobby_message = cmd.reason; + } + if (msg.type == message::FRAME_INFO) { resume_frame_counter++; diff --git a/network/network.h b/network/network.h index a737d926..c681e6fd 100644 --- a/network/network.h +++ b/network/network.h @@ -6,6 +6,7 @@ #include #include "network/message.h" #include "network/entities.h" +#include "network/session.h" #include "input/command.h" namespace network @@ -65,6 +66,10 @@ namespace network command_queue::commands_map client_commands_queue; + protected: + // drops peers whose socket is gone, together with their crew memberships + void prune_clients(); + public: server(std::shared_ptr buf); void push_delta(const frame_info &msg); @@ -105,6 +110,9 @@ namespace network void update(); std::tuple get_next_delta(int counter); void send_commands(command_queue::commands_map commands); + // lobby requests; the server is the one that decides + void send_claim(NetworkEntityId entity_id); + void send_leave(NetworkEntityId entity_id); int get_frame_counter() { return resume_frame_counter; } diff --git a/network/session.cpp b/network/session.cpp new file mode 100644 index 00000000..824a8b6a --- /dev/null +++ b/network/session.cpp @@ -0,0 +1,266 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "network/session.h" + +#include "input/command.h" +#include "simulation/simulation.h" +#include "utilities/Globals.h" +#include "utilities/Logs.h" +#include "utilities/Timer.h" +#include "vehicle/Driver.h" +#include "vehicle/DynObj.h" +#include "vehicle/Train.h" + +namespace network +{ + +crew_registry Crews; + +std::string describe(claim_result Result) +{ + switch (Result) + { + case claim_result::granted: + return "granted"; + case claim_result::already_member: + return "already a crew member"; + case claim_result::unknown_vehicle: + return "no such vehicle in this session"; + case claim_result::vehicle_missing: + return "the vehicle is not present in the scenario"; + case claim_result::crew_full: + return "the crew of this vehicle is full"; + } + return "unknown"; +} + +vehicle_crew &crew_registry::entry(NetworkEntityId Id) +{ + auto lookup = m_vehicles.find(Id); + if (lookup == m_vehicles.end()) + { + vehicle_crew fresh; + fresh.entity_id = Id; + lookup = m_vehicles.emplace(Id, fresh).first; + } + return lookup->second; +} + +claim_result crew_registry::claim(PeerId Peer, NetworkEntityId Id) +{ + vehicle_entry const *published = Entities.find(Id); + if (published == nullptr) + return claim_result::unknown_vehicle; + + if (Entities.resolve(Id) == nullptr) + return claim_result::vehicle_missing; + + vehicle_crew &vehicle = entry(Id); + + if (std::find(vehicle.crew.begin(), vehicle.crew.end(), Peer) != vehicle.crew.end()) + return claim_result::already_member; + + if (vehicle.crew.size() >= published->crew_capacity) + return claim_result::crew_full; + + if (vehicle.crew.empty()) + { + // remember whether this vehicle was actually being driven by the AI, so that it is + // handed back only to a driver that was really there before + TDynamicObject const *dynamic = Entities.resolve(Id); + vehicle.ai_before_claim = (dynamic != nullptr && dynamic->Mechanik != nullptr && dynamic->Mechanik->AIControllFlag); + vehicle.ai_restore_pending = false; + } + + vehicle.crew.emplace_back(Peer); + m_pending.emplace_back(Id); + + WriteLog("net: peer " + std::to_string(Peer) + " joined the crew of " + published->name + " (" + std::to_string(vehicle.crew.size()) + "/" + + std::to_string((int)published->crew_capacity) + ")", + logtype::net); + + return claim_result::granted; +} + +bool crew_registry::leave(PeerId Peer, NetworkEntityId Id) +{ + auto lookup = m_vehicles.find(Id); + if (lookup == m_vehicles.end()) + return false; + + auto &crew = lookup->second.crew; + auto member = std::find(crew.begin(), crew.end(), Peer); + if (member == crew.end()) + return false; + + crew.erase(member); + m_pending.emplace_back(Id); + + WriteLog("net: peer " + std::to_string(Peer) + " left the crew of " + Entities.name_of(Id) + " (" + std::to_string(crew.size()) + " left)", logtype::net); + + if (crew.empty() && lookup->second.ai_before_claim) + { + // the vehicle came with an AI driver, so it gets one back + lookup->second.ai_restore_pending = true; + } + + return true; +} + +void crew_registry::drop_peer(PeerId Peer) +{ + for (auto &pair : m_vehicles) + { + if (std::find(pair.second.crew.begin(), pair.second.crew.end(), Peer) != pair.second.crew.end()) + leave(Peer, pair.first); + } +} + +std::vector crew_registry::crew_of(NetworkEntityId Id) const +{ + auto const lookup = m_vehicles.find(Id); + return lookup != m_vehicles.end() ? lookup->second.crew : std::vector(); +} + +bool crew_registry::is_member(PeerId Peer, NetworkEntityId Id) const +{ + auto const lookup = m_vehicles.find(Id); + if (lookup == m_vehicles.end()) + return false; + return std::find(lookup->second.crew.begin(), lookup->second.crew.end(), Peer) != lookup->second.crew.end(); +} + +uint8_t crew_registry::count(NetworkEntityId Id) const +{ + auto const lookup = m_vehicles.find(Id); + return lookup != m_vehicles.end() ? (uint8_t)lookup->second.crew.size() : (uint8_t)0; +} + +NetworkEntityId crew_registry::vehicle_of(PeerId Peer) const +{ + for (auto const &pair : m_vehicles) + { + if (std::find(pair.second.crew.begin(), pair.second.crew.end(), Peer) != pair.second.crew.end()) + return pair.first; + } + return ENTITY_NONE; +} + +void crew_registry::mirror(NetworkEntityId Id, std::vector const &Crew) +{ + entry(Id).crew = Crew; +} + +std::vector crew_registry::take_pending_updates() +{ + std::vector pending; + pending.swap(m_pending); + + // the same vehicle can change more than once between two publications + std::sort(pending.begin(), pending.end()); + pending.erase(std::unique(pending.begin(), pending.end()), pending.end()); + + return pending; +} + +void crew_registry::mark_all_pending() +{ + for (auto const &pair : m_vehicles) + { + if (!pair.second.crew.empty()) + m_pending.emplace_back(pair.first); + } +} + +void crew_registry::clear() +{ + m_vehicles.clear(); + m_pending.clear(); +} + +namespace +{ + +// posts a command the way an input source would, but without going through command_relay: +// the authority acts on behalf of the session, not on behalf of the local camera, so it +// must not be filtered by the local free fly state +void post_authority_command(user_command Command, uint32_t Recipient, glm::vec3 const &Location, std::string const &Payload) +{ + command_data data; + data.command = Command; + data.action = GLFW_PRESS; + data.param1 = 0.0; + data.param2 = 0.0; + data.time_delta = Timer::GetDeltaTime(); + data.freefly = true; + data.location = Location; + data.payload = Payload; + + simulation::Commands.push(data, Recipient); +} + +} // namespace + +// frames to wait before repeating an authority request that has not taken effect yet +static int const ACTION_COOLDOWN_FRAMES = 30; + +void crew_registry::reconcile() +{ + for (auto &pair : m_vehicles) + { + vehicle_crew &vehicle = pair.second; + + TDynamicObject *dynamic = Entities.resolve(vehicle.entity_id); + if (dynamic == nullptr) + continue; + + if (vehicle.action_cooldown > 0) + { + --vehicle.action_cooldown; + continue; + } + + TTrain *train = simulation::Trains.find(dynamic->name()); + + if (!vehicle.crew.empty()) + { + if (train == nullptr) + { + // the cab has to exist on every peer, so the authority asks for it with the + // ordinary replicated command instead of building it locally + post_authority_command(user_command::entervehicle, (uint32_t)command_target::simulation, dynamic->GetPosition(), dynamic->name()); + vehicle.action_cooldown = ACTION_COOLDOWN_FRAMES; + continue; + } + + if (dynamic->Mechanik != nullptr && dynamic->Mechanik->AIControllFlag) + { + // first human on board takes over from the AI + WriteLog("net: " + dynamic->name() + " taken over from the AI driver", logtype::net); + post_authority_command(user_command::aidriverdisable, (uint32_t)command_target::vehicle | train->id(), dynamic->GetPosition(), std::string()); + vehicle.action_cooldown = ACTION_COOLDOWN_FRAMES; + } + continue; + } + + if (vehicle.ai_restore_pending && train != nullptr) + { + // aidriverenable resets the driver, so it must happen exactly once + vehicle.ai_restore_pending = false; + vehicle.ai_before_claim = false; + WriteLog("net: handing " + dynamic->name() + " back to the AI driver", logtype::net); + post_authority_command(user_command::aidriverenable, (uint32_t)command_target::vehicle | train->id(), dynamic->GetPosition(), std::string()); + vehicle.action_cooldown = ACTION_COOLDOWN_FRAMES; + } + } +} + +} // namespace network diff --git a/network/session.h b/network/session.h new file mode 100644 index 00000000..e2026e74 --- /dev/null +++ b/network/session.h @@ -0,0 +1,90 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include "network/entities.h" + +#include +#include +#include + +namespace network +{ + +// who is working on a given vehicle. a vehicle can hold more than one person, which is +// the whole point of the crew model - the container is not limited by design, only by +// the capacity the server advertises +struct vehicle_crew +{ + NetworkEntityId entity_id{ENTITY_NONE}; + std::vector crew; + // whether the AI was actually driving before the first human arrived. a vehicle that + // nobody was driving must not suddenly acquire an AI driver when its crew leaves + bool ai_before_claim{false}; + // set on the 1 -> 0 transition, cleared once the AI has been handed the vehicle back + bool ai_restore_pending{false}; + // keeps reconcile() from re-posting the same request every single frame while the + // previous one is still on its way through the command queue + int action_cooldown{0}; +}; + +enum class claim_result +{ + granted, + already_member, + unknown_vehicle, + vehicle_missing, + crew_full +}; + +std::string describe(claim_result Result); + +// crew bookkeeping. authoritative on the server; on a client this is a read only mirror +// fed by CREW_UPDATE, used by the lobby +class crew_registry +{ +public: + claim_result claim(PeerId Peer, NetworkEntityId Id); + // returns true when the peer really was part of the crew + bool leave(PeerId Peer, NetworkEntityId Id); + // removes a peer from every crew it belonged to, e.g. after a disconnect + void drop_peer(PeerId Peer); + + std::vector crew_of(NetworkEntityId Id) const; + bool is_member(PeerId Peer, NetworkEntityId Id) const; + uint8_t count(NetworkEntityId Id) const; + NetworkEntityId vehicle_of(PeerId Peer) const; + + // client side: takes over the crew of one vehicle as published by the server + void mirror(NetworkEntityId Id, std::vector const &Crew); + + // server side: brings the world in line with the crew roster - creates the cab of a + // claimed vehicle, hands the vehicle over from the AI and hands it back when the last + // person leaves. safe to call every frame, it only acts when something is out of place + void reconcile(); + + // ids whose crew changed since the last call + std::vector take_pending_updates(); + // queues every crew for republication, so that a peer which just went active learns + // about the vehicles that were taken over before it arrived + void mark_all_pending(); + + void clear(); + +private: + vehicle_crew &entry(NetworkEntityId Id); + + std::unordered_map m_vehicles; + std::vector m_pending; +}; + +extern crew_registry Crews; + +} // namespace network diff --git a/utilities/Globals.h b/utilities/Globals.h index 03e34290..23f02b47 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -358,6 +358,10 @@ struct global_settings { uint32_t network_peer_id = 0; // vehicle the local player asked to enter from the multiplayer lobby std::string network_pending_vehicle; + // set when the local player left the crew and should go back to observing + bool network_leave_pending = false; + // last answer of the server to a lobby request, shown in the lobby + std::string network_lobby_message; std::unordered_map trainset_overrides; diff --git a/widgets/multiplayer_lobby.cpp b/widgets/multiplayer_lobby.cpp index 9c6420fe..2feac4a6 100644 --- a/widgets/multiplayer_lobby.cpp +++ b/widgets/multiplayer_lobby.cpp @@ -12,10 +12,11 @@ http://mozilla.org/MPL/2.0/. #include "application/application.h" #include "network/entities.h" +#include "network/session.h" #include "simulation/simulation.h" #include "utilities/Globals.h" -#include "utilities/utilities.h" #include "utilities/translation.h" +#include "utilities/utilities.h" #include "vehicle/Driver.h" #include "vehicle/DynObj.h" #include "vehicle/Train.h" @@ -23,21 +24,30 @@ http://mozilla.org/MPL/2.0/. ui::multiplayer_lobby_panel::multiplayer_lobby_panel() : ui_panel(STR_C("Multiplayer lobby"), false) { - size_min = {480, 260}; + size_min = {520, 280}; } -void ui::multiplayer_lobby_panel::enter_vehicle(std::string const &Name) +std::string ui::multiplayer_lobby_panel::describe_crew(network::NetworkEntityId const Entity) const { - TDynamicObject *vehicle = simulation::Vehicles.find(Name); - if (vehicle == nullptr) - return; + auto const crew = network::Crews.crew_of(Entity); + if (crew.empty()) + return std::string(); - std::string payload{Name}; - // the request travels as an ordinary simulation command, so every peer builds the cab - m_relay.post(user_command::entervehicle, 0.0, simulation::Train ? simulation::Train->id() : 0, GLFW_PRESS, 0, vehicle->GetPosition(), &payload); - // ...and the driver mode moves our own camera in once the cab shows up locally - Global.network_pending_vehicle = Name; - is_open = false; + std::string description; + for (network::PeerId const peer : crew) + { + if (!description.empty()) + description += ", "; + + if (peer == Global.network_peer_id) + description += STR("you"); + else if (peer == network::PEER_HOST) + description += STR("host"); + else + description += STR("player") + " " + std::to_string(peer); + } + + return description; } void ui::multiplayer_lobby_panel::render_contents() @@ -53,6 +63,10 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::Text("%s: %s, peer %u", STR_C("Session"), host ? (client ? "host + client" : "host") : "client", Global.network_peer_id); ImGui::TextUnformatted(Global.SceneryFile.c_str()); + + if (!Global.network_lobby_message.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.3f, 1.0f), "%s", Global.network_lobby_message.c_str()); + ImGui::Separator(); auto const &entries = network::Entities.entries(); @@ -62,24 +76,18 @@ void ui::multiplayer_lobby_panel::render_contents() return; } - std::string const currentvehicle{simulation::Train != nullptr && simulation::Train->Dynamic() != nullptr ? simulation::Train->Dynamic()->name() : std::string()}; - - if (!FreeFlyModeFlag) - { - ImGui::TextUnformatted(STR_C("Leave the cab to change vehicles.")); - ImGui::Separator(); - } + network::NetworkEntityId const ownvehicle{network::Crews.vehicle_of(Global.network_peer_id)}; ImGui::Columns(4, "mp_vehicles", false); - ImGui::SetColumnWidth(1, 80.0f * Global.ui_scale); - ImGui::SetColumnWidth(2, 90.0f * Global.ui_scale); + ImGui::SetColumnWidth(1, 70.0f * Global.ui_scale); + ImGui::SetColumnWidth(2, 200.0f * Global.ui_scale); ImGui::SetColumnWidth(3, 130.0f * Global.ui_scale); ImGui::TextDisabled("%s", STR_C("Vehicle")); ImGui::NextColumn(); ImGui::TextDisabled("%s", STR_C("Crew")); ImGui::NextColumn(); - ImGui::TextDisabled("%s", STR_C("Driver")); + ImGui::TextDisabled("%s", STR_C("On board")); ImGui::NextColumn(); ImGui::NextColumn(); ImGui::Separator(); @@ -94,21 +102,28 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::Text("%u/%u", (unsigned)entry.crew_count, (unsigned)entry.crew_capacity); ImGui::NextColumn(); - ImGui::TextUnformatted(entry.crew_count > 0 ? STR_C("player") : entry.ai_active ? STR_C("AI") : STR_C("free")); + std::string const crew{describe_crew(entry.id)}; + if (!crew.empty()) + ImGui::TextUnformatted(crew.c_str()); + else + ImGui::TextDisabled("%s", entry.ai_active ? STR_C("AI") : STR_C("free")); ImGui::NextColumn(); - if (entry.name == currentvehicle) + if (entry.id == ownvehicle) { - ImGui::TextUnformatted(STR_C("you")); + if (ImGui::Button(STR_C("Leave"), ImVec2(-1, 0))) + Application.request_vehicle_leave(entry.id); } - else if (entry.claimable && FreeFlyModeFlag && network::Entities.resolve(entry.id) != nullptr) + else if (entry.claimable) { - if (ImGui::Button(STR_C("Enter"), ImVec2(-1, 0))) - enter_vehicle(entry.name); + // a vehicle somebody else is already working on can still be joined, that is + // what the crew capacity is for + if (ImGui::Button(entry.crew_count > 0 ? STR_C("Join crew") : STR_C("Enter"), ImVec2(-1, 0))) + claim(entry.id, ownvehicle); } else { - ImGui::TextDisabled("%s", entry.claimable ? "-" : STR_C("occupied")); + ImGui::TextDisabled("%s", STR_C("full")); } ImGui::NextColumn(); @@ -117,3 +132,14 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::Columns(1); } + +void ui::multiplayer_lobby_panel::claim(network::NetworkEntityId const Entity, network::NetworkEntityId const Current) +{ + if (Current != network::ENTITY_NONE) + { + // one person cannot work two vehicles at once + Application.request_vehicle_leave(Current); + } + + Application.request_vehicle_claim(Entity); +} diff --git a/widgets/multiplayer_lobby.h b/widgets/multiplayer_lobby.h index a57e58e5..4a09f188 100644 --- a/widgets/multiplayer_lobby.h +++ b/widgets/multiplayer_lobby.h @@ -10,7 +10,7 @@ http://mozilla.org/MPL/2.0/. #pragma once #include "application/uilayer.h" -#include "input/command.h" +#include "network/entities.h" namespace ui { @@ -27,9 +27,8 @@ public: void render_contents() override; private: - void enter_vehicle(std::string const &Name); - - command_relay m_relay; + void claim(network::NetworkEntityId const Entity, network::NetworkEntityId const Current); + std::string describe_crew(network::NetworkEntityId const Entity) const; }; } // namespace ui