From 0b75e701fc596e86295bd479e7d5fcf645f4d8b0 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Sun, 6 Sep 2026 23:39:13 +0000 Subject: [PATCH] multiplayer: a player works a train, not a car - and put the corrections right Second live test. Four things, three of them the same mistake seen from different angles: the session was reasoning about single vehicles where it should have been reasoning about trains. - a peer may now operate anything coupled into the set it is sitting in. This is what "cabchangebackward rejected: not a member of the crew of that vehicle" was: the crew was recorded against one car of a four-car unit, and the cab switch addressed another - every vehicle gets a network id, not only the ones worth driving. Commands are addressed by that id, and a player has to be able to walk into any car of their train. "peer 3 cannot take en71-017rb_4: no such vehicle in this session" was the driving trailer of an EN71 - a full desk, no engine, and nobody assigned to it, so the old test missed it. Having a driving position is what counts now, engine or not - the roster groups vehicles into the trains they are coupled into and the lobby lists one row per train. Coupling is worked out afresh every time, so a train that comes apart becomes two, and because a crew entry sits on the car its player occupies, each half keeps the people who were riding in it - walking to another car of your own train is not a new claim and asks nothing of the crew capacity, which counts people per train Positional correction never actually did anything: it identified a track by name, and most track in a scenery has none, so the lookup returned nothing and place_on_track() was never reached. The client sat 178 m out of place for minutes, reporting four vehicles "put back on the rails" every time while moving none of them. Tracks now travel as their index in the path table, which comes from the scenery file and is therefore the same on every peer. Also from the test: - the camera jumped in and out of the cab on the client whenever the host reversed, because the correction was forcing the server's CabActive and CabOccupied onto it. Which cab somebody sits in is theirs alone - two people sharing a vehicle each have their own - so it is no longer replicated - the correction no longer touches the control positions of the train the local player is working. Their crew's commands reach every peer on their own, and snapping a lever back a fraction of a second after it was moved is what made the reverser look dead - the frame buffer was sized for the old lockstep, up to sixty frames deep. Every frame in it is input lag, so it is down to twelve with a target of one - asking the AI of a vehicle to step aside is given five tries and then said once, instead of every thirty frames forever; and a resync request now has a real cooldown --- network/entities.cpp | 126 +++++++++++++++++++++++++++++++--- network/entities.h | 27 ++++++-- network/message.cpp | 8 ++- network/network.cpp | 6 +- network/network.h | 12 +++- network/session.cpp | 81 +++++++++++++++++++--- network/session.h | 10 +++ network/snapshot.cpp | 110 +++++++++++++++++++++++------ widgets/multiplayer_lobby.cpp | 29 ++++++-- 9 files changed, 357 insertions(+), 52 deletions(-) diff --git a/network/entities.cpp b/network/entities.cpp index 2e33aee1..270eeab5 100644 --- a/network/entities.cpp +++ b/network/entities.cpp @@ -44,10 +44,11 @@ bool is_drivable(TDynamicObject const *Vehicle) if (Vehicle == nullptr || Vehicle->MoverParameters == nullptr) return false; - // anything with its own drive, plus anything the scenario already put a driver in - // (this covers driving trailers of multiple units, which carry no engine of their own) - // TODO: a cab count exposed by TMoverParameters would be a more precise test - return Vehicle->MoverParameters->EngineType != TEngineType::None || Vehicle->Mechanik != nullptr; + auto const &mover = *Vehicle->MoverParameters; + + // a driving position is what counts, not an engine: the driving trailer of a multiple + // unit has a full desk and no traction of its own, and the scenario puts nobody in it + return mover.EngineType != TEngineType::None || Vehicle->Mechanik != nullptr || mover.MainCtrlPosNo > 0 || mover.BrakeCtrlPosNo > 0; } void entity_registry::clear() @@ -61,10 +62,12 @@ void entity_registry::build() { clear(); + // every vehicle gets an id, not only the ones worth driving. commands are addressed by + // it, and a player working a train may walk into any of its cars std::vector names; for (TDynamicObject *vehicle : simulation::Vehicles.sequence()) { - if (!is_drivable(vehicle)) + if (vehicle == nullptr) continue; names.emplace_back(vehicle->name()); } @@ -88,10 +91,22 @@ void entity_registry::build() m_byname.emplace(name, entry.id); } + if (m_entries.size() > 0xffff) + { + // the command channel packs the recipient into sixteen bits + ErrorLog("net: scenario holds more vehicles than the command channel can address", logtype::net); + } + reindex(); refresh(); - WriteLog("net: assigned network ids to " + std::to_string(m_entries.size()) + " drivable vehicles", logtype::net); + uint32_t drivable{0}; + for (auto const &entry : m_entries) + if (entry.drivable) + ++drivable; + + WriteLog("net: assigned network ids to " + std::to_string(m_entries.size()) + " vehicles, " + std::to_string(drivable) + " of them with a driving position", + logtype::net); } void entity_registry::adopt(std::vector const &Entries) @@ -112,15 +127,110 @@ void entity_registry::reindex() void entity_registry::refresh() { + // only there so that a malformed consist cannot spin us forever + int const CONSIST_LIMIT{256}; + for (auto &entry : m_entries) { TDynamicObject const *vehicle = resolve(entry.id); + entry.consist_id = entry.id; + entry.consist_size = 1; + entry.consist_lead = false; + entry.drivable = is_drivable(vehicle); entry.ai_active = (vehicle != nullptr && vehicle->Mechanik != nullptr && vehicle->Mechanik->AIControllFlag); - entry.crew_count = Crews.count(entry.id); entry.crew_capacity = CREW_CAPACITY; - entry.claimable = (vehicle != nullptr && entry.crew_count < entry.crew_capacity); } + + // group the vehicles into the trains they are currently coupled into. this is redone + // every time, so a train that comes apart turns into two trains here, and the crews + // follow whichever half each player happens to be sitting in + std::unordered_set visited; + + for (auto const &entry : m_entries) + { + TDynamicObject *vehicle = resolve(entry.id); + if (vehicle == nullptr || visited.count(vehicle) > 0) + continue; + + TDynamicObject *front = vehicle; + for (int step = 0; step < CONSIST_LIMIT; ++step) + { + TDynamicObject *previous = front->Prev(); + if (previous == nullptr || previous == vehicle || visited.count(previous) > 0) + break; + front = previous; + } + + std::vector members; + TDynamicObject *member = front; + for (int step = 0; step < CONSIST_LIMIT && member != nullptr; ++step, member = member->Next()) + { + if (visited.count(member) > 0) + break; + visited.emplace(member); + + auto const id = id_of(member->name()); + if (id != ENTITY_NONE) + members.emplace_back(id); + } + + if (members.empty()) + continue; + + // the lowest id in the set names the train, and the lowest one that can actually + // be driven stands for it in the lobby + NetworkEntityId consist{members.front()}; + NetworkEntityId lead{ENTITY_NONE}; + uint8_t crew{0}; + + for (auto const id : members) + { + consist = std::min(consist, id); + crew = (uint8_t)std::min(255, crew + Crews.count(id)); + + auto const *candidate = find(id); + if (candidate != nullptr && candidate->drivable && (lead == ENTITY_NONE || id < lead)) + lead = id; + } + if (lead == ENTITY_NONE) + lead = consist; + + for (auto const id : members) + { + auto const lookup = m_byid.find(id); + if (lookup == m_byid.end()) + continue; + + auto &target = m_entries[lookup->second]; + target.consist_id = consist; + target.consist_size = (uint8_t)std::min(255, members.size()); + target.consist_lead = (id == lead); + target.crew_count = crew; + target.claimable = (crew < target.crew_capacity); + } + } +} + +NetworkEntityId entity_registry::consist_of(NetworkEntityId Id) const +{ + auto const *entry = find(Id); + return entry != nullptr ? entry->consist_id : ENTITY_NONE; +} + +std::vector entity_registry::consist_members(NetworkEntityId Consist) const +{ + std::vector members; + if (Consist == ENTITY_NONE) + return members; + + for (auto const &entry : m_entries) + { + if (entry.consist_id == Consist) + members.emplace_back(entry.id); + } + + return members; } NetworkEntityId entity_registry::id_of(std::string const &Name) const diff --git a/network/entities.h b/network/entities.h index 2e749a65..d743a8fb 100644 --- a/network/entities.h +++ b/network/entities.h @@ -36,20 +36,35 @@ constexpr NetworkEntityId ENTITY_NONE = 0; // so raising this number does not call for a structural change constexpr uint8_t CREW_CAPACITY = 2; -// authoritative description of a single drivable vehicle, as published by the server +// authoritative description of a single vehicle, as published by the server. +// every vehicle is listed, not only the ones worth driving: commands are addressed by +// this id, and a player may walk into any member of the train they are working struct vehicle_entry { NetworkEntityId id{ENTITY_NONE}; std::string name; + // the train this vehicle is currently coupled into, named by the lowest id in the set. + // a player belongs to a train, not to a single car, so this is what the crew, the + // permissions and the lobby all work with. it is recomputed as vehicles couple and + // uncouple, which is what splits a crew between the halves of a train that came apart + NetworkEntityId consist_id{ENTITY_NONE}; + // how many vehicles that train has, and whether this entry is the one that stands for + // it in the lobby + uint8_t consist_size{1}; + bool consist_lead{false}; + // crew of the whole train, not of this vehicle alone uint8_t crew_count{0}; uint8_t crew_capacity{CREW_CAPACITY}; bool ai_active{false}; bool claimable{false}; + // has a driving position of its own + bool drivable{false}; bool operator==(vehicle_entry const &Other) const { - return id == Other.id && name == Other.name && crew_count == Other.crew_count && crew_capacity == Other.crew_capacity && ai_active == Other.ai_active && - claimable == Other.claimable; + return id == Other.id && name == Other.name && consist_id == Other.consist_id && consist_size == Other.consist_size && consist_lead == Other.consist_lead && + crew_count == Other.crew_count && crew_capacity == Other.crew_capacity && ai_active == Other.ai_active && claimable == Other.claimable && + drivable == Other.drivable; } bool operator!=(vehicle_entry const &Other) const { return !(*this == Other); } }; @@ -74,6 +89,9 @@ public: NetworkEntityId id_of(std::string const &Name) const; NetworkEntityId id_of(TDynamicObject const *Vehicle) const; + // the train a vehicle belongs to, and everything coupled into it + NetworkEntityId consist_of(NetworkEntityId Id) const; + std::vector consist_members(NetworkEntityId Consist) const; std::string name_of(NetworkEntityId Id) const; TDynamicObject *resolve(NetworkEntityId Id) const; vehicle_entry const *find(NetworkEntityId Id) const; @@ -98,7 +116,8 @@ bool is_multiplayer(); // of a session do; a client does not, it only carries out what the server hands it bool is_authority(); -// true for vehicles a player can take over +// true for vehicles that carry a driving position of their own. note that a driving +// trailer has one without having an engine, and without the scenario putting a driver in bool is_drivable(TDynamicObject const *Vehicle); } // namespace network diff --git a/network/message.cpp b/network/message.cpp index b525b80d..87a27def 100644 --- a/network/message.cpp +++ b/network/message.cpp @@ -57,9 +57,11 @@ void network::vehicle_list::serialize(std::ostream &stream) const { sn_utils::ls_uint32(stream, entry.id); sn_utils::s_str(stream, entry.name); + sn_utils::ls_uint32(stream, entry.consist_id); + sn_utils::s_uint8(stream, entry.consist_size); sn_utils::s_uint8(stream, entry.crew_count); sn_utils::s_uint8(stream, entry.crew_capacity); - sn_utils::s_uint8(stream, (uint8_t)((entry.ai_active ? 1 : 0) | (entry.claimable ? 2 : 0))); + sn_utils::s_uint8(stream, (uint8_t)((entry.ai_active ? 1 : 0) | (entry.claimable ? 2 : 0) | (entry.drivable ? 4 : 0) | (entry.consist_lead ? 8 : 0))); } } @@ -75,11 +77,15 @@ void network::vehicle_list::deserialize(std::istream &stream) vehicle_entry entry; entry.id = sn_utils::ld_uint32(stream); entry.name = sn_utils::d_str(stream); + entry.consist_id = sn_utils::ld_uint32(stream); + entry.consist_size = sn_utils::d_uint8(stream); entry.crew_count = sn_utils::d_uint8(stream); entry.crew_capacity = sn_utils::d_uint8(stream); uint8_t const flags = sn_utils::d_uint8(stream); entry.ai_active = (flags & 1) != 0; entry.claimable = (flags & 2) != 0; + entry.drivable = (flags & 4) != 0; + entry.consist_lead = (flags & 8) != 0; vehicles.emplace_back(entry); } } diff --git a/network/network.cpp b/network/network.cpp index 266c7be5..1f2ec880 100644 --- a/network/network.cpp +++ b/network/network.cpp @@ -524,8 +524,12 @@ void network::client::handle_message(std::shared_ptr conn, const mes // the routine correction is what keeps us in line; a full resync is only worth // asking for when even that is not catching up if (result.worst_position_error > RESYNC_POSITION_ERROR) { - if (++bad_corrections >= RESYNC_BAD_CORRECTIONS) { + ++bad_corrections; + + if ((bad_corrections >= RESYNC_BAD_CORRECTIONS) + && (last_resync_tick == 0 || Global.simulation_tick > last_resync_tick + RESYNC_COOLDOWN_TICKS)) { bad_corrections = 0; + last_resync_tick = Global.simulation_tick; WriteLog("net: " + std::to_string((int)result.worst_position_error) + " m out of place after a correction, asking for a full resync", logtype::net); send_resync_request(Global.simulation_tick, 0); diff --git a/network/network.h b/network/network.h index e29c9360..436cd74b 100644 --- a/network/network.h +++ b/network/network.h @@ -101,9 +101,12 @@ namespace network size_t reconnect_delay = 0; const size_t RECONNECT_DELAY_FRAMES = 60; - const float MAX_BUFFER_SIZE = 60.0f; + // the queue of authoritative frames waiting to be played out. every frame sitting + // in here is a frame of delay between pressing a key and seeing it happen, so it + // is kept as short as the connection allows rather than a comfortable second deep + const float MAX_BUFFER_SIZE = 12.0f; const float JITTERINESS_MIX = 0.998f; - const float TARGET_MIN = 2.0f; + const float TARGET_MIN = 1.0f; const float TARGET_MIX = 0.98f; const float JITTERINESS_MULTIPIER = 2.0f; const float CONSUME_MULTIPIER = 0.05f; @@ -113,10 +116,13 @@ namespace network // how far out of place a correction found us, in a row. a full resync is only // worth asking for when the routine stream is not catching up on its own int bad_corrections = 0; + uint64_t last_resync_tick = 0; static constexpr double RESYNC_POSITION_ERROR = 25.0; static constexpr int RESYNC_BAD_CORRECTIONS = 5; + // a correction needs time to take hold; asking again before it has is pointless + static constexpr uint64_t RESYNC_COOLDOWN_TICKS = 600; - float last_target = 20.0f; + float last_target = 2.0f; float jitteriness = 1.0f; float consume_counter = 0.0f; diff --git a/network/session.cpp b/network/session.cpp index 75200076..0022657b 100644 --- a/network/session.cpp +++ b/network/session.cpp @@ -70,8 +70,7 @@ command_verdict validate_command(PeerId Peer, user_command Command, uint32_t Rec if (target == command_target::vehicle) { - // a player works the controls of the vehicle they are on, and of no other - return Crews.is_member(Peer, (NetworkEntityId)(Recipient & 0xffff)) ? command_verdict::accepted : command_verdict::not_in_crew; + return may_control(Peer, (NetworkEntityId)(Recipient & 0xffff)) ? command_verdict::accepted : command_verdict::not_in_crew; } if (target == command_target::simulation) @@ -93,11 +92,24 @@ claim_result claim_by_name(PeerId Peer, std::string const &Vehicle, NetworkEntit return claim_result::unknown_vehicle; auto const previous = Crews.vehicle_of(Peer); + if (previous == Entity) + return claim_result::already_member; + + if ((previous != ENTITY_NONE) && (Entities.consist_of(previous) == Entities.consist_of(Entity))) + { + // walking to another car of the train you are already working is not a new claim, + // so it asks nothing of the capacity. the seat still moves, because which car a + // player sits in decides which half they keep when the train comes apart + Crews.leave(Peer, previous); + Crews.take_seat(Peer, Entity); + return claim_result::granted; + } + auto const result = Crews.claim(Peer, Entity); - if ((result == claim_result::granted) && (previous != ENTITY_NONE) && (previous != Entity)) + if ((result == claim_result::granted) && (previous != ENTITY_NONE)) { - // nobody works two vehicles at once + // nobody works two trains at once Crews.leave(Peer, previous); } @@ -152,6 +164,21 @@ void filter_commands(PeerId Peer, command_queue::commands_map &Commands) } } +bool may_control(PeerId Peer, NetworkEntityId Entity) +{ + auto const seat = Crews.vehicle_of(Peer); + if (seat == ENTITY_NONE) + return false; + + auto const train = Entities.consist_of(Entity); + if (train == ENTITY_NONE) + return false; + + // the whole set the player is sitting in, so that the desk of a driving trailer works + // on the motor cars behind it and the cab switches of a multiple unit go through + return Entities.consist_of(seat) == train; +} + vehicle_crew &crew_registry::entry(NetworkEntityId Id) { auto lookup = m_vehicles.find(Id); @@ -178,7 +205,8 @@ claim_result crew_registry::claim(PeerId Peer, NetworkEntityId 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) + // capacity counts the people on the whole train, not on this one car + if (published->crew_count >= published->crew_capacity) return claim_result::crew_full; if (vehicle.crew.empty()) @@ -373,6 +401,23 @@ void crew_registry::mirror(NetworkEntityId Id, std::vector const &Crew) entry(Id).crew = Crew; } +void crew_registry::take_seat(PeerId Peer, NetworkEntityId Id) +{ + auto &vehicle = entry(Id); + if (std::find(vehicle.crew.begin(), vehicle.crew.end(), Peer) != vehicle.crew.end()) + return; + + if (vehicle.crew.empty()) + { + 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); +} + std::vector crew_registry::take_pending_updates() { std::vector pending; @@ -425,6 +470,8 @@ void post_authority_command(user_command Command, uint32_t Recipient, glm::vec3 // frames to wait before repeating an authority request that has not taken effect yet static int const ACTION_COOLDOWN_FRAMES = 30; +// how many times to ask a vehicle's AI driver to step aside before giving up on it +static int const AI_HANDOVER_ATTEMPTS = 5; void crew_registry::reconcile() { @@ -457,10 +504,26 @@ void crew_registry::reconcile() 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; + if (vehicle.ai_attempts < AI_HANDOVER_ATTEMPTS) + { + // first human on board takes over from the AI + if (vehicle.ai_attempts == 0) + WriteLog("net: " + dynamic->name() + " taken over from the AI driver", logtype::net); + + ++vehicle.ai_attempts; + post_authority_command(user_command::aidriverdisable, (uint32_t)command_target::vehicle | train->id(), dynamic->GetPosition(), std::string()); + vehicle.action_cooldown = ACTION_COOLDOWN_FRAMES; + } + else if (vehicle.ai_attempts == AI_HANDOVER_ATTEMPTS) + { + // asking again would only fill the log; say so once and leave it + ++vehicle.ai_attempts; + ErrorLog("net: " + dynamic->name() + " will not let go of its AI driver", logtype::net); + } + } + else + { + vehicle.ai_attempts = 0; } continue; } diff --git a/network/session.h b/network/session.h index 937aeb33..73878318 100644 --- a/network/session.h +++ b/network/session.h @@ -34,6 +34,9 @@ struct vehicle_crew // 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}; + // how many times we have asked the AI to hand this vehicle over. if it will not, that + // is worth saying once rather than asking forever + int ai_attempts{0}; }; enum class claim_result @@ -74,6 +77,9 @@ public: // client side: takes over the crew of one vehicle as published by the server void mirror(NetworkEntityId Id, std::vector const &Crew); + // puts a peer on a vehicle without asking about capacity; used when somebody walks to + // another car of the train they are already working + void take_seat(PeerId Peer, NetworkEntityId Id); // 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 @@ -138,6 +144,10 @@ std::string describe(command_verdict Verdict); // simply holds every right - there is no separate path for the local participant command_verdict validate_command(PeerId Peer, user_command Command, uint32_t Recipient); +// a player works a train, not a single car: they may operate anything coupled into the +// set they are sitting in, which is what makes the cab switches of a multiple unit work +bool may_control(PeerId Peer, NetworkEntityId Entity); + // walking into a cab with the in-game key is the same request as pressing the button in // the lobby, so it goes through the crew registry rather than being treated as a command // that reaches the whole world. returns the vehicle it resolved to, if any diff --git a/network/snapshot.cpp b/network/snapshot.cpp index dbc4d65a..c2c3482c 100644 --- a/network/snapshot.cpp +++ b/network/snapshot.cpp @@ -38,6 +38,45 @@ double const REPOSITION_TOLERANCE_CORRECTION{2.5}; // --------------------------------------------------------------------------- // what the authority last sent, so that a routine correction only carries changes +// most tracks in a scenery carry no name at all, so a name is useless as an identity. +// their order in the path table comes straight from the scenery file and is therefore the +// same on every peer, which makes the index a usable one +std::vector g_trackindex; +std::unordered_map g_trackids; + +void refresh_track_index() +{ + auto const &paths = simulation::Paths.sequence(); + if (g_trackindex.size() == paths.size()) + return; + + g_trackindex.assign(paths.begin(), paths.end()); + g_trackids.clear(); + for (uint32_t i = 0; i < g_trackindex.size(); ++i) + { + if (g_trackindex[i] != nullptr) + g_trackids.emplace(g_trackindex[i], i); + } +} + +uint32_t track_id_of(TTrack const *Track) +{ + if (Track == nullptr) + return 0xffffffff; + + refresh_track_index(); + + auto const lookup = g_trackids.find(Track); + return lookup != g_trackids.end() ? lookup->second : 0xffffffff; +} + +TTrack *track_by_id(uint32_t const Id) +{ + refresh_track_index(); + + return (Id < g_trackindex.size() ? g_trackindex[Id] : nullptr); +} + std::unordered_map g_lastvehicles; std::unordered_map g_lastmemcells; std::unordered_map g_lastswitches; @@ -137,7 +176,7 @@ void read_session(std::istream &Stream, network::snapshot_mode const Mode) struct vehicle_state { std::string name; - std::string track; + uint32_t track{0xffffffff}; double translation{0.0}; uint8_t axlefirst{0}; uint8_t direction{0}; @@ -164,8 +203,6 @@ struct vehicle_state bool battery{false}; bool converterallow{false}; bool compressorallow{false}; - int32_t cabactive{0}; - int32_t caboccupied{0}; std::array pantenabled{{false, false}}; std::array pantdisabled{{false, false}}; std::array pantactive{{false, false}}; @@ -179,7 +216,7 @@ struct vehicle_state void serialize_vehicle(std::ostream &Stream, vehicle_state const &State) { sn_utils::s_str(Stream, State.name); - sn_utils::s_str(Stream, State.track); + sn_utils::ls_uint32(Stream, State.track); sn_utils::ls_float64(Stream, State.translation); sn_utils::s_uint8(Stream, State.axlefirst); sn_utils::s_uint8(Stream, State.direction); @@ -204,8 +241,6 @@ void serialize_vehicle(std::ostream &Stream, vehicle_state const &State) sn_utils::s_bool(Stream, State.battery); sn_utils::s_bool(Stream, State.converterallow); sn_utils::s_bool(Stream, State.compressorallow); - sn_utils::ls_int32(Stream, State.cabactive); - sn_utils::ls_int32(Stream, State.caboccupied); for (int i = 0; i < 2; ++i) { sn_utils::s_bool(Stream, State.pantenabled[i]); @@ -227,7 +262,7 @@ vehicle_state deserialize_vehicle(std::istream &Stream) vehicle_state state; state.name = sn_utils::d_str(Stream); - state.track = sn_utils::d_str(Stream); + state.track = sn_utils::ld_uint32(Stream); state.translation = sn_utils::ld_float64(Stream); state.axlefirst = sn_utils::d_uint8(Stream); state.direction = sn_utils::d_uint8(Stream); @@ -252,8 +287,6 @@ vehicle_state deserialize_vehicle(std::istream &Stream) state.battery = sn_utils::d_bool(Stream); state.converterallow = sn_utils::d_bool(Stream); state.compressorallow = sn_utils::d_bool(Stream); - state.cabactive = sn_utils::ld_int32(Stream); - state.caboccupied = sn_utils::ld_int32(Stream); for (int i = 0; i < 2; ++i) { state.pantenabled[i] = sn_utils::d_bool(Stream); @@ -279,7 +312,7 @@ vehicle_state read_from(TDynamicObject const &Vehicle) vehicle_state state; state.name = Vehicle.name(); - state.track = (track != nullptr ? track->name() : std::string()); + state.track = track_id_of(track); state.translation = Vehicle.RaTranslationGet(); state.axlefirst = (uint8_t)(Vehicle.iAxleFirst ? 1 : 0); state.direction = (uint8_t)(Vehicle.iDirection ? 1 : 0); @@ -304,8 +337,6 @@ vehicle_state read_from(TDynamicObject const &Vehicle) state.battery = mover.Battery; state.converterallow = mover.ConverterAllow; state.compressorallow = mover.CompressorAllow; - state.cabactive = mover.CabActive; - state.caboccupied = mover.CabOccupied; for (int i = 0; i < 2; ++i) { state.pantenabled[i] = mover.Pantographs[i].valve.is_enabled; @@ -324,19 +355,41 @@ vehicle_state read_from(TDynamicObject const &Vehicle) return state; } +// true for the train the local player is working. its controls are driven by the commands +// its crew issue, which every peer already receives, so the correction must keep its hands +// off them - otherwise it keeps snapping a lever back a fraction of a second after the +// player moved it, and the control looks broken +bool is_own_train(std::string const &Name) +{ + auto const seat = network::Crews.vehicle_of(Global.network_peer_id); + if (seat == network::ENTITY_NONE) + return false; + + auto const train = network::Entities.consist_of(network::Entities.id_of(Name)); + return (train != network::ENTITY_NONE) && (network::Entities.consist_of(seat) == train); +} + // applies everything except where the vehicle is; position is dealt with per consist, // because moving one vehicle of a coupled set on its own tears the couplers apart void apply_controls(TDynamicObject &Vehicle, vehicle_state const &State) { auto &mover = *Vehicle.MoverParameters; + // what the physics produced is always taken from the authority; what a person set is + // not, on the train that person is working + bool const ours = is_own_train(State.name); + mover.V = State.velocity; mover.Vel = State.velocitykmh; - mover.DirActive = State.diractive; - mover.MainCtrlPos = State.mainctrl; - mover.ScndCtrlPos = State.scndctrl; - mover.BrakeCtrlPos = State.brakectrl; - mover.LocalBrakePosA = State.localbrake; + + if (!ours) + { + mover.DirActive = State.diractive; + mover.MainCtrlPos = State.mainctrl; + mover.ScndCtrlPos = State.scndctrl; + mover.BrakeCtrlPos = State.brakectrl; + mover.LocalBrakePosA = State.localbrake; + } mover.PipePress = State.pipepress; mover.BrakePress = State.brakepress; @@ -345,6 +398,14 @@ void apply_controls(TDynamicObject &Vehicle, vehicle_state const &State) mover.Compressor = State.compressor; mover.CompressedVolume = State.compressedvolume; + if (ours) + { + // the rest of it is what the crew set, and their commands reach us on their own + if (Vehicle.Mechanik != nullptr && Vehicle.Mechanik->AIControllFlag != State.aiactive) + Vehicle.Mechanik->TakeControl(State.aiactive); + return; + } + // the appliances go through the vehicle's own switches wherever it has them, so that // whatever they drag along stays consistent. range local: every vehicle carries its // own state in the update, there is no need to push it down the consist twice @@ -357,8 +418,9 @@ void apply_controls(TDynamicObject &Vehicle, vehicle_state const &State) if (mover.CompressorAllow != State.compressorallow) mover.CompressorSwitch(State.compressorallow, range_t::local); - mover.CabActive = State.cabactive; - mover.CabOccupied = State.caboccupied; + // CabActive and CabOccupied are deliberately not replicated: which cab a player is + // sitting in is theirs alone, and two people sharing a vehicle each have their own. + // forcing the server's value on them is what made the camera jump in and out for (int i = 0; i < 2; ++i) { @@ -388,9 +450,17 @@ void apply_controls(TDynamicObject &Vehicle, vehicle_state const &State) // its leading bogie; this inverts the arithmetic that function itself does void reposition(TDynamicObject &Vehicle, vehicle_state const &State) { - TTrack *track = (State.track.empty() ? nullptr : simulation::Paths.find(State.track)); + TTrack *track = track_by_id(State.track); if (track == nullptr) + { + static bool reported{false}; + if (!reported) + { + reported = true; + ErrorLog("net: cannot place " + Vehicle.name() + ", track " + std::to_string(State.track) + " is unknown here", logtype::net); + } return; + } double const half = Vehicle.fAxleDist * 0.5; double const axleoffset = (State.axlefirst ? -half : half); diff --git a/widgets/multiplayer_lobby.cpp b/widgets/multiplayer_lobby.cpp index 08ae6fc2..4e3d9261 100644 --- a/widgets/multiplayer_lobby.cpp +++ b/widgets/multiplayer_lobby.cpp @@ -32,7 +32,14 @@ ui::multiplayer_lobby_panel::multiplayer_lobby_panel() std::string ui::multiplayer_lobby_panel::describe_crew(network::NetworkEntityId const Entity) const { - auto const crew = network::Crews.crew_of(Entity); + // the people on the whole train, wherever in it they happen to be sitting + std::vector crew; + for (auto const member : network::Entities.consist_members(network::Entities.consist_of(Entity))) + { + for (auto const peer : network::Crews.crew_of(member)) + crew.emplace_back(peer); + } + if (crew.empty()) return std::string(); @@ -93,7 +100,7 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::SetColumnWidth(2, statewidth); ImGui::SetColumnWidth(3, actionwidth); - ImGui::TextDisabled("%s", STR_C("Vehicle")); + ImGui::TextDisabled("%s", STR_C("Train")); ImGui::NextColumn(); ImGui::TextDisabled("%s", STR_C("Crew")); ImGui::NextColumn(); @@ -102,11 +109,21 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::NextColumn(); ImGui::Separator(); + network::NetworkEntityId const owntrain{network::Entities.consist_of(ownvehicle)}; + for (auto const &entry : entries) { + // one row per train. a player takes the whole set, not a single car, and moves + // between its cars freely once they are on it + if (!entry.consist_lead) + continue; + ImGui::PushID(static_cast(entry.id)); - ImGui::TextUnformatted(entry.name.c_str()); + if (entry.consist_size > 1) + ImGui::Text("%s +%u", entry.name.c_str(), (unsigned)(entry.consist_size - 1)); + else + ImGui::TextUnformatted(entry.name.c_str()); ImGui::NextColumn(); ImGui::Text("%u/%u", (unsigned)entry.crew_count, (unsigned)entry.crew_capacity); @@ -119,10 +136,10 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::TextDisabled("%s", entry.ai_active ? STR_C("AI") : STR_C("free")); ImGui::NextColumn(); - if (entry.id == ownvehicle) + if (owntrain != network::ENTITY_NONE && entry.consist_id == owntrain) { if (ImGui::Button(STR_C("Leave"))) - Application.request_vehicle_leave(entry.id); + Application.request_vehicle_leave(ownvehicle); } else if (entry.claimable) { @@ -143,7 +160,7 @@ void ui::multiplayer_lobby_panel::render_contents() ImGui::Columns(1); ImGui::Separator(); - ImGui::TextDisabled("%s", STR_C("You can also walk up to a vehicle and use the enter-vehicle key.")); + ImGui::TextDisabled("%s", STR_C("You can also walk up to a train and use the enter-vehicle key.")); } void ui::multiplayer_lobby_panel::claim(network::NetworkEntityId const Entity, network::NetworkEntityId const Current)