From 4870935983e0b2895798414f9fd3aeb3033e6714 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Sun, 6 Sep 2026 19:09:19 +0000 Subject: [PATCH] multiplayer stage 5: the authority decides which events fire Every peer used to evaluate event conditions for itself and put the results straight into its own queue, so the same event could fire twice, or on one machine only. Worse, the launchers were polled around Global.pCamera, which in a session means "wherever one particular player happens to be looking". Condition evaluation is now separated from execution: authority: evaluate -> queueevent -> execute -> replicate client: receive queueevent -> execute - AddToQuery() takes the origin of the request. A locally evaluated launch on a client is dropped, on the host it is handed to the session as the ordinary queueevent command and comes back to be carried out by everybody, itself included, so it happens exactly once per peer. Scenario init (onstart, negative delay) and the children of an already replicated event are marked as authority decisions and run in place, since they are identical everywhere. With no session running, both branches collapse to the old behaviour - queueevent now carries the delay as well - event launchers are polled around every crewed vehicle instead of around the local camera, and a cell is polled at most once per frame no matter how many players stand near it - launcher activation counters are stateful - Events.update() still services the queue on a client, with its delays and its execution; only the condition checking is skipped there An event whose request is still travelling is not requested again, with a timeout so that a lost request cannot silence it for good. Instant (AddValues) events are exempt from that guard, because every one of their additions has to get through. --- network/entities.cpp | 11 ++++++ network/entities.h | 6 +++ scene/scene.cpp | 62 ++++++++++++++++++++++++------- scene/scene.h | 12 +++++- simulation/simulation.cpp | 12 +++++- world/Event.cpp | 77 +++++++++++++++++++++++++++++++++++---- world/Event.h | 17 ++++++++- 7 files changed, 172 insertions(+), 25 deletions(-) diff --git a/network/entities.cpp b/network/entities.cpp index d8c78b74..2e33aee1 100644 --- a/network/entities.cpp +++ b/network/entities.cpp @@ -16,6 +16,7 @@ http://mozilla.org/MPL/2.0/. #include "vehicle/Driver.h" #include "vehicle/Train.h" #include "utilities/Logs.h" +#include "utilities/Globals.h" namespace network { @@ -28,6 +29,16 @@ PeerId allocate_peer_id() return next++; } +bool is_multiplayer() +{ + return Global.network_client.has_value() || !Global.network_servers.empty(); +} + +bool is_authority() +{ + return !Global.network_client.has_value(); +} + bool is_drivable(TDynamicObject const *Vehicle) { if (Vehicle == nullptr || Vehicle->MoverParameters == nullptr) diff --git a/network/entities.h b/network/entities.h index 4b87fdc0..2e749a65 100644 --- a/network/entities.h +++ b/network/entities.h @@ -92,6 +92,12 @@ extern entity_registry Entities; // hands out ids for connecting peers; PEER_HOST is reserved for the local participant PeerId allocate_peer_id(); +// true when this run takes part in a session at all, as host or as client +bool is_multiplayer(); +// true when this peer decides what happens in the world. a standalone game and the host +// 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 bool is_drivable(TDynamicObject const *Vehicle); diff --git a/scene/scene.cpp b/scene/scene.cpp index 14825705..871c79be 100644 --- a/scene/scene.cpp +++ b/scene/scene.cpp @@ -21,6 +21,7 @@ http://mozilla.org/MPL/2.0/. #include "scene/sn_utils.h" #include "rendering/renderer.h" #include "widgets/map_objects.h" +#include "network/entities.h" namespace scene { @@ -121,20 +122,20 @@ basic_cell::update_traction( TDynamicObject *Vehicle, int const Pantographindex // legacy method, updates sounds and polls event launchers within radius around specified point void -basic_cell::update_events() { +basic_cell::update_events( event_poll_point const &Point ) { // event launchers for( auto *launcher : m_eventlaunchers ) { - glm::dvec3 campos = Global.pCamera.Pos; + glm::dvec3 referencepoint = Point.location; double radius = launcher->dRadius; - if (launcher->train_triggered && simulation::Train) { - campos = simulation::Train->Dynamic()->HeadPosition(); - radius *= Timer::GetDeltaTime() * simulation::Train->Dynamic()->GetVelocity() * 0.277; + if( launcher->train_triggered && Point.vehicle != nullptr ) { + referencepoint = Point.vehicle->HeadPosition(); + radius *= Timer::GetDeltaTime() * Point.vehicle->GetVelocity() * 0.277; } if( launcher->check_conditions() && ( radius < 0.0 - || glm::distance2( launcher->location(), campos ) < launcher->dRadius ) ) { + || glm::distance2( launcher->location(), referencepoint ) < launcher->dRadius ) ) { if( launcher->check_activation() ) launch_event( launcher, true ); if( launcher->check_activation_key() ) @@ -728,13 +729,17 @@ basic_section::update_traction( TDynamicObject *Vehicle, int const Pantographind // legacy method, polls event launchers within radius around specified point void -basic_section::update_events( glm::dvec3 const &Location, float const Radius ) { +basic_section::update_events( std::vector const &Points, float const Radius ) { for( auto &cell : m_cells ) { - if( glm::length2( cell.area().center - Location ) < sq(cell.area().radius + Radius) ) { - // we reject cells which aren't within our area of interest - cell.update_events(); + for( auto const &point : Points ) { + if( glm::length2( cell.area().center - point.location ) < sq(cell.area().radius + Radius) ) { + cell.update_events( point ); + // a cell is polled at most once per frame, no matter how many players are + // standing near it - launcher activation counters are stateful + break; + } } } } @@ -1053,11 +1058,42 @@ basic_region::update_events() { if( false == simulation::is_ready ) { return; } - // render events and sounds from sectors near enough to the viewer + if( false == network::is_authority() ) { + // a client does not decide what fires; it carries out what the server sends it + return; + } + auto const range = EU07_SECTIONSIZE; // arbitrary range - auto const §ionlist = sections( Global.pCamera.Pos, range ); + + std::vector points; + points.emplace_back( event_poll_point{ + Global.pCamera.Pos, + ( simulation::Train != nullptr ? simulation::Train->Dynamic() : nullptr ) } ); + + if( true == network::is_multiplayer() ) { + // gameplay may not hang on where the host happens to be looking, so the launchers + // are polled around every vehicle somebody is actually working + for( auto const &entry : network::Entities.entries() ) { + if( entry.crew_count == 0 ) { continue; } + auto const *vehicle { simulation::Vehicles.find( entry.name ) }; + if( vehicle == nullptr ) { continue; } + if( simulation::Train != nullptr && simulation::Train->Dynamic() == vehicle ) { continue; } + + points.emplace_back( event_poll_point{ vehicle->HeadPosition(), vehicle } ); + } + } + + // sections() hands back a scratchpad it reuses, so the union has to be collected first + std::vector sectionlist; + for( auto const &point : points ) { + auto const &nearby = sections( point.location, range ); + sectionlist.insert( sectionlist.end(), nearby.begin(), nearby.end() ); + } + std::sort( sectionlist.begin(), sectionlist.end() ); + sectionlist.erase( std::unique( sectionlist.begin(), sectionlist.end() ), sectionlist.end() ); + for( auto *section : sectionlist ) { - section->update_events( Global.pCamera.Pos, range ); + section->update_events( points, range ); } } diff --git a/scene/scene.h b/scene/scene.h index 9f5201f8..37779a08 100644 --- a/scene/scene.h +++ b/scene/scene.h @@ -31,6 +31,14 @@ class opengl33_renderer; namespace scene { +// a place the event launchers are polled around. in a single player game there is just +// one of these - the camera - but a session has to look at every crewed vehicle, because +// gameplay may not depend on where any single player happens to be pointing their camera +struct event_poll_point { + glm::dvec3 location; + TDynamicObject const *vehicle { nullptr }; +}; + int constexpr EU07_CELLSIZE = 250; int constexpr EU07_SECTIONSIZE = 1000; int constexpr EU07_REGIONSIDESECTIONCOUNT = 500; // number of sections along a side of square region @@ -93,7 +101,7 @@ public: update_traction( TDynamicObject *Vehicle, int const Pantographindex ); // legacy method, polls event launchers within radius around specified point void - update_events(); + update_events( event_poll_point const &Point ); // legacy method, updates sounds within radius around specified point void update_sounds(); @@ -264,7 +272,7 @@ public: update_traction( TDynamicObject *Vehicle, int const Pantographindex ); // legacy method, updates sounds and polls event launchers within radius around specified point void - update_events( glm::dvec3 const &Location, float const Radius ); + update_events( std::vector const &Points, float const Radius ); // legacy method, updates sounds and polls event launchers within radius around specified point void update_sounds( glm::dvec3 const &Location, float const Radius ); diff --git a/simulation/simulation.cpp b/simulation/simulation.cpp index a1493f63..16bf9377 100644 --- a/simulation/simulation.cpp +++ b/simulation/simulation.cpp @@ -295,16 +295,26 @@ void state_manager::process_commands() { std::string event_name; std::string vehicle_name; + std::string delay_text; std::getline(ss, event_name, '%'); std::getline(ss, vehicle_name, '%'); + std::getline(ss, delay_text, '%'); basic_event *ev = Events.FindEvent(event_name); TDynamicObject *vehicle = nullptr; if (!vehicle_name.empty()) vehicle = simulation::Vehicles.find(vehicle_name); + double delay = 0.0; + if (!delay_text.empty()) { + try { delay = std::stod(delay_text); } + catch (std::exception const &) { delay = 0.0; } + } + + // this is the authority speaking, whether it reached us over the wire or came + // straight from our own event manager if (ev) - Events.AddToQuery(ev, vehicle); + Events.AddToQuery(ev, vehicle, delay, event_launch::authority); } if (commanddata.command == user_command::setlight) { diff --git a/world/Event.cpp b/world/Event.cpp index 4e042b29..1b9ad488 100644 --- a/world/Event.cpp +++ b/world/Event.cpp @@ -33,6 +33,7 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Timer.h" #include "utilities/Logs.h" #include "widgets/map_objects.h" +#include "network/entities.h" void basic_event::event_conditions::bind( basic_event::node_sequence *Nodes ) { @@ -1306,13 +1307,15 @@ multi_event::run_() { if( std::get( childwrapper ) != conditiontest ) { continue; } if( childevent != this ) { - // normalnie dodać - simulation::Events.AddToQuery( childevent, m_activator ); + // normalnie dodać. + // the parent already ran on every peer, so its children are not a local + // decision any more and must not be sent around a second time + simulation::Events.AddToQuery( childevent, m_activator, 0.0, event_launch::authority ); } else { // jeśli ma być rekurencja to musi mieć sensowny okres powtarzania if( m_delay >= 5.0 ) { - simulation::Events.AddToQuery( this, m_activator ); + simulation::Events.AddToQuery( this, m_activator, 0.0, event_launch::authority ); } } } @@ -2288,8 +2291,15 @@ event_manager::queue_receivers( radio_message const Message, glm::dvec3 const &L void event_manager::update() { - // process currently queued events + // process currently queued events. this runs on every peer: a client still has a + // queue to service, with its delays and its execution CheckQuery(); + + if( false == network::is_authority() ) { + // evaluating the conditions, however, is the authority's job alone + return; + } + // test list of global events for possible new additions to the queue for( auto *launcher : m_launcherqueue ) { if (launcher->check_conditions() && launcher->Event1) { @@ -2357,8 +2367,9 @@ event_manager::insert( basic_event *Event ) { m_eventmap.emplace( Event->m_name, m_events.size() - 1 ); if( Event->m_ignored != true && contains(Event->m_name, "onstart") ) { - // event uruchamiany automatycznie po starcie - AddToQuery( Event, nullptr ); + // event uruchamiany automatycznie po starcie. + // scenario init runs identically on every peer, so it needs no replication + AddToQuery( Event, nullptr, 0.0, event_launch::authority ); } } @@ -2393,9 +2404,58 @@ event_manager::FindEvent( std::string const &Name ) return FindEventById(GetEventId(Name)); } +// how long a request handed to the session is considered still on its way. it only has to +// outlast the round trip through the command queue; the ceiling keeps a request that got +// lost somewhere from silencing its event for good +double const REPLICATION_TIMEOUT { 5.0 }; + +// hands a locally evaluated launch over to the session authority +bool +event_manager::defer_to_authority( basic_event *Event, TDynamicObject const *Owner, double delay ) { + + if( Event->m_passive ) { return false; } + if( Event->m_inqueue > 0 ) { return false; } + + // an event whose request is still travelling must not be requested again, or a + // condition that stays true would flood the session. instant events are exempt: they + // are edge driven and every single one of their additions has to get through + if( ( false == Event->is_instant() ) + && ( Event->m_replicationtime >= 0.0 ) + && ( Timer::GetTime() - Event->m_replicationtime < REPLICATION_TIMEOUT ) ) { + return false; + } + + Event->m_replicationtime = Timer::GetTime(); + + // the very same command a scenario or a script would use; it comes back through the + // command queue and is carried out by every peer, this one included + auto const payload { + Event->m_name + + "%" + ( Owner != nullptr ? Owner->name() : std::string() ) + + "%" + std::to_string( delay ) }; + + m_relay.post( user_command::queueevent, 0.0, 0.0, GLFW_PRESS, 0, glm::vec3( 0.0f ), &payload ); + + WriteLog( "net: event replicated: " + Event->m_name + ( Owner != nullptr ? " (by " + Owner->name() + ")" : "" ), logtype::net ); + + return true; +} + // legacy method, inserts specified event in the event query bool -event_manager::AddToQuery( basic_event *Event, TDynamicObject const *Owner, double delay ) { +event_manager::AddToQuery( basic_event *Event, TDynamicObject const *Owner, double delay, event_launch const Source ) { + + if( Event == nullptr ) { return false; } + + if( ( Source == event_launch::evaluation ) + && ( true == network::is_multiplayer() ) ) { + // the world does not decide on its own what happens in a session: the authority + // evaluates the condition and hands the outcome to everybody + if( false == network::is_authority() ) { return false; } + return defer_to_authority( Event, Owner, delay ); + } + + Event->m_replicationtime = -1.0; if( Event->m_passive ) { return false; } // jeśli może być dodany do kolejki (nie używany w skanowaniu) if( Event->m_inqueue > 0 ) { return false; } // jeśli nie dodany jeszcze do kolejki @@ -2512,7 +2572,8 @@ event_manager::InitEvents() { //łączenie eventów z pozostałymi obiektami for( auto *event : m_events ) { event->init(); - if( event->m_delay < 0 ) { AddToQuery( event, nullptr ); } + // scenario init runs identically on every peer, so it needs no replication + if( event->m_delay < 0 ) { AddToQuery( event, nullptr, 0.0, event_launch::authority ); } } } diff --git a/world/Event.h b/world/Event.h index 52ee3468..540a01cf 100644 --- a/world/Event.h +++ b/world/Event.h @@ -21,6 +21,14 @@ http://mozilla.org/MPL/2.0/. #include "scripting/lua.h" #endif +// where a request to put an event in the queue came from. in a session only the +// authority evaluates conditions; everybody, the authority included, runs the event when +// it comes back as a replicated queueevent, so it fires exactly once on every peer +enum class event_launch { + evaluation, // somebody's local condition check + authority // decided by the session authority, or by deterministic scenario init +}; + // common event interface class basic_event { @@ -98,6 +106,7 @@ public: double m_delay { 0.0 }; double m_delayrandom { 0.0 }; // zakres dodatkowego opóźnienia // standardowo nie będzie dodatkowego losowego opóźnienia double m_delaydeparture { std::numeric_limits::quiet_NaN() }; // departure-based event delay + double m_replicationtime { -1.0 }; // when the authority last handed this one to the session protected: // types @@ -683,7 +692,8 @@ public: } // legacy method, inserts specified event in the event query bool - AddToQuery( basic_event *Event, TDynamicObject const *Owner, double delay = 0.0 ); + AddToQuery( basic_event *Event, TDynamicObject const *Owner, double delay = 0.0, + event_launch const Source = event_launch::evaluation ); // legacy method, executes queued events bool CheckQuery(); @@ -719,6 +729,11 @@ private: basic_table m_radiodrivenlaunchers; eventlauncher_sequence m_launcherqueue; command_relay m_relay; +// methods + // hands a locally evaluated launch over to the session, so that it reaches every peer + // through the ordinary queueevent command instead of firing here alone + bool + defer_to_authority( basic_event *Event, TDynamicObject const *Owner, double delay ); };