diff --git a/CMakeLists.txt b/CMakeLists.txt index 8603f93b..9bbda082 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -197,10 +197,12 @@ set(SOURCES "network/session.cpp" "network/statehash.cpp" "network/snapshot.cpp" +"network/chat.cpp" "network/backend/asio.cpp" "widgets/vehiclelist.cpp" "widgets/multiplayer_lobby.cpp" +"widgets/chat_panel.cpp" "widgets/map.cpp" "widgets/map_objects.cpp" "widgets/popup.cpp" diff --git a/application/application.cpp b/application/application.cpp index ffcf1e61..44dfd58d 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -220,7 +220,7 @@ void eu07_application::DiscordRPCService() discord_rpc.largeImageText = "MaSzyna"; // run loop - while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty() && !Global.applicationQuitOrder) + while (!should_close() && !m_modestack.empty() && !Global.applicationQuitOrder) { auto currentMode = m_modestack.top(); if (currentMode == mode::launcher) @@ -363,7 +363,7 @@ int eu07_application::init(int Argc, char *Argv[]) return result; } - if (crashreport_is_pending()) + if (crashreport_is_pending() && !Global.headless) { // run crashgui as early as possible if ((result = run_crashgui()) != 0) { @@ -429,7 +429,10 @@ void eu07_application::queue_quit(bool direct) // down everybody else's session as well if (direct || is_client() || !m_modes[m_modestack.top()]->is_command_processor()) { - glfwSetWindowShouldClose(m_windows[0], GLFW_TRUE); + if (Global.headless) + Global.applicationQuitOrder = true; + else + glfwSetWindowShouldClose(m_windows[0], GLFW_TRUE); return; } @@ -475,14 +478,21 @@ void eu07_application::network_scenario_loaded() m_network->notify_scenario_loaded(); } +void eu07_application::say(std::string const &Text) +{ + if (m_network) + m_network->say(Text); +} + int eu07_application::run() { auto frame{0}; // main application loop - while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty()) + while (!should_close() && !m_modestack.empty()) { Timer::subsystem.mainloop_total.start(); - glfwPollEvents(); + if (!Global.headless) + glfwPollEvents(); if (m_headtrack) m_headtrack->update(); @@ -645,7 +655,9 @@ int eu07_application::run() if (m_modestack.empty()) break; - m_modes[m_modestack.top()]->on_event_poll(); + // there is no keyboard, mouse or gamepad to ask when there is no window + if (!Global.headless) + m_modes[m_modestack.top()]->on_event_poll(); if (m_screenshot_queued) { @@ -718,15 +730,21 @@ void eu07_application::exit() // SafeDelete( simulation::Train ); SafeDelete(simulation::Region); - ui_layer::shutdown(); - - for (auto *window : m_windows) + if (!Global.headless) { - glfwDestroyWindow(window); + ui_layer::shutdown(); + + for (auto *window : m_windows) + { + glfwDestroyWindow(window); + } } m_taskqueue.exit(); - glfwPollEvents(); // TODO: This fixes a segfault on Wayland when closing. Remove after updating glfw to 3.5. - glfwTerminate(); + if (!Global.headless) + { + glfwPollEvents(); // TODO: This fixes a segfault on Wayland when closing. Remove after updating glfw to 3.5. + glfwTerminate(); + } if (!Global.exec_on_exit.empty()) system(Global.exec_on_exit.c_str()); @@ -750,6 +768,11 @@ void eu07_application::exit() void eu07_application::render_ui() { + if (Global.headless) + { + return; + } + if (m_modestack.empty()) { @@ -761,6 +784,11 @@ void eu07_application::render_ui() void eu07_application::begin_ui_frame() { + // there is no imgui context without a window, and nothing would look at its output + if (Global.headless) + { + return; + } if (m_modestack.empty()) { @@ -811,6 +839,8 @@ bool eu07_application::push_mode(mode const Mode) void eu07_application::set_title(std::string const &Title) { + if (m_windows.empty()) + return; glfwSetWindowTitle(m_windows.front(), Title.c_str()); } @@ -845,6 +875,8 @@ void eu07_application::set_cursor(int const Mode) void eu07_application::set_cursor_pos(double const Horizontal, double const Vertical) { + if (m_windows.empty()) + return; glfwSetCursorPos(m_windows.front(), Horizontal, Vertical); } @@ -1006,7 +1038,17 @@ std::string eu07_application::describe_monitor(GLFWmonitor *monitor) const bool eu07_application::needs_ogl() const { - return !Global.NvRenderer; + return !Global.NvRenderer && !Global.headless; +} + +// there is no window to be closed when running without one, so the only way out is being +// asked to leave +bool eu07_application::should_close() const +{ + if (Global.headless) + return Global.applicationQuitOrder; + + return m_windows.empty() || glfwWindowShouldClose(m_windows.front()); } void eu07_application::init_debug() @@ -1173,6 +1215,9 @@ void print_usage(std::string const &Executable) << EU07_DEFAULT_NETWORK_PORT << ")\n" << " --connect join a multiplayer session (default port " << EU07_DEFAULT_NETWORK_PORT << ")\n" + << " --nick name to be known by in the session\n" + << " --nogui no window, no renderer, no cab: a dedicated\n" + << " server that leaves the scenario to the AI\n" << " -h, --help this message" << std::endl; } @@ -1234,6 +1279,33 @@ int eu07_application::init_settings(int Argc, char *Argv[]) } Global.network_client.emplace("tcp", network_endpoint(Argv[++i], "127.0.0.1")); } + else if (token == "--nick") + { + if (i + 1 >= Argc) + { + std::cout << "--nick requires a name" << std::endl; + return -1; + } + Global.multiplayer_nickname = Argv[++i]; + } + else if (token == "--nogui") + { + // no window, no renderer, no player. the log goes to the console it was + // started from, because there is nothing else to read it in + Global.headless = true; + Global.GfxRenderer = "null"; + // nothing is waiting on a swapchain, so without this the loop would spin a + // core flat out for no benefit. fifty steps a second is more than the + // authoritative stream needs + Global.minframetime = std::chrono::duration(1.0f / 50.0f); + Global.iWriteLogEnabled = 2; + Global.ShowSystemConsole = true; + Global.bSoundEnabled = false; + // the scenario is left to its own drivers: this process is a referee, not a + // participant, and it never walks into a cab + Global.local_start_vehicle = "ghostview"; + Global.local_start_vehicle_override = true; + } else if (token == "-h" || token == "--help") { print_usage(Argv[0]); @@ -1259,6 +1331,14 @@ int eu07_application::init_locale() int eu07_application::init_glfw() { + if (Global.headless) + { + // no window system at all. everything downstream is guarded on an empty window + // list, which is what tells the rest of the application there is nothing to draw on + WriteLog("running without a window; this process is a dedicated server"); + return 0; + } + { int glfw_major, glfw_minor, glfw_rev; glfwGetVersion(&glfw_major, &glfw_minor, &glfw_rev); @@ -1440,6 +1520,9 @@ int eu07_application::init_ogl() int eu07_application::init_ui() { + if (Global.headless) + return 0; + if (false == ui_layer::init(m_windows.front())) { return -1; @@ -1451,7 +1534,13 @@ int eu07_application::init_ui() int eu07_application::init_gfx() { - if (Global.GfxRenderer == "default") + if (Global.headless) + { + // draws nothing, allocates nothing, and lets the rest of the simulation run + // exactly as it otherwise would + GfxRenderer = gfx_renderer_factory::get_instance()->create("null"); + } + else if (Global.GfxRenderer == "default") { // default render path GfxRenderer = gfx_renderer_factory::get_instance()->create("modern"); @@ -1473,7 +1562,7 @@ int eu07_application::init_gfx() return -1; } - if (false == GfxRenderer->Init(m_windows.front())) + if (false == GfxRenderer->Init(m_windows.empty() ? nullptr : m_windows.front())) { return -1; } @@ -1555,7 +1644,11 @@ bool eu07_application::init_network() // the host takes part in the session like any other peer, with an identity of its // own, so that crew and permission handling needs no special case for it if (!Global.network_client) + { Global.network_peer_id = network::PEER_HOST; + auto const name = network::Peers.assign(network::PEER_HOST, network::local_nickname()); + WriteLog("net: hosting as \"" + name + "\"", logtype::net); + } } for (auto const &pair : Global.network_servers) diff --git a/application/application.h b/application/application.h index 98c76bf6..7bf79be3 100644 --- a/application/application.h +++ b/application/application.h @@ -98,6 +98,9 @@ public: // tells the network layer the scenario finished loading void network_scenario_loaded(); + // says something to the rest of the session + void + say( std::string const &Text ); private: // types @@ -105,6 +108,9 @@ private: using mode_stack = std::stack; // methods bool needs_ogl() const; + // true when the run is over: the window was closed, or, with no window, we were asked + // to stop + bool should_close() const; void init_debug(); void init_console(); void init_files(); diff --git a/application/drivermode.cpp b/application/drivermode.cpp index c77ebf82..9c5f0134 100644 --- a/application/drivermode.cpp +++ b/application/drivermode.cpp @@ -540,6 +540,20 @@ void driver_mode::on_key(int const Key, int const Scancode, int const Action, in bool anyModifier = Mods & (GLFW_MOD_SHIFT | GLFW_MOD_CONTROL | GLFW_MOD_ALT); + // the key left of 1 opens and closes the session chat. it is only bound in a session, + // so single player keeps it for whatever else may want it later. while the chat has + // the keyboard this is never reached: imgui swallows the input before it gets here, + // which is exactly what stops typed letters from working the cab + if ((Key == GLFW_KEY_GRAVE_ACCENT) && (Action == GLFW_PRESS) && !anyModifier && network::is_multiplayer()) + { + auto ui = std::dynamic_pointer_cast(m_userinterface); + if (ui != nullptr) + { + ui->toggle_chat(); + return; + } + } + // give the ui first shot at the input processing... if (!anyModifier && true == m_userinterface->on_key(Key, Action)) { diff --git a/application/driveruilayer.cpp b/application/driveruilayer.cpp index 197a6ceb..5e1aaba4 100644 --- a/application/driveruilayer.cpp +++ b/application/driveruilayer.cpp @@ -38,6 +38,7 @@ driver_ui::driver_ui() add_external_panel(&m_perfgraphpanel); add_external_panel(&m_cameraviewpanel); add_external_panel(&m_multiplayerlobby); + add_external_panel(&m_chatpanel); m_logpanel.is_open = false; m_aidpanel.title = STR("Driving Aid"); @@ -54,6 +55,8 @@ driver_ui::driver_ui() m_transcriptspanel.size_min = {435, 85}; m_transcriptspanel.size_max = {Global.fb_size.x * 0.95, Global.fb_size.y * 0.95}; + m_chatpanel.title = STR("Chat"); + m_multiplayerlobby.title = STR("Multiplayer lobby"); m_multiplayerlobby.size_min = {560, 320}; m_multiplayerlobby.size_max = {Global.fb_size.x * 0.95f, Global.fb_size.y * 0.95f}; @@ -93,7 +96,10 @@ void driver_ui::render_menu_contents() m_timepanel.open(); if (Application.is_server() || Application.is_client()) + { ImGui::MenuItem(m_multiplayerlobby.name().c_str(), nullptr, &m_multiplayerlobby.is_open); + ImGui::MenuItem(m_chatpanel.name().c_str(), "`", &m_chatpanel.is_open); + } ImGui::EndMenu(); } @@ -104,6 +110,13 @@ void driver_ui::show_multiplayer_lobby(bool const Show) m_multiplayerlobby.is_open = Show; } +void driver_ui::toggle_chat() +{ + m_chatpanel.is_open = !m_chatpanel.is_open; + if (m_chatpanel.is_open) + m_chatpanel.focus_input(); +} + void driver_ui::showDebugUI() { m_debugpanel.is_open = !m_debugpanel.is_open; diff --git a/application/driveruilayer.h b/application/driveruilayer.h index 2c33b2dc..1fd84bf7 100644 --- a/application/driveruilayer.h +++ b/application/driveruilayer.h @@ -21,6 +21,7 @@ http://mozilla.org/MPL/2.0/. #include "widgets/perfgraphs.h" #include "widgets/cameraview_extcam.h" #include "widgets/multiplayer_lobby.h" +#include "widgets/chat_panel.h" class driver_ui : public ui_layer { @@ -44,6 +45,9 @@ public: // opens or closes the multiplayer lobby void show_multiplayer_lobby( bool const Show ); + // opens or closes the session chat, putting the cursor in the box when it opens + void + toggle_chat(); protected: void render_menu_contents() override; @@ -74,4 +78,5 @@ private: ui::time_panel m_timepanel; ui::cameraview_panel m_cameraviewpanel; ui::multiplayer_lobby_panel m_multiplayerlobby; + ui::chat_panel m_chatpanel; }; diff --git a/application/uilayer.cpp b/application/uilayer.cpp index 60c8a778..00ff8273 100644 --- a/application/uilayer.cpp +++ b/application/uilayer.cpp @@ -473,7 +473,9 @@ void ui_layer::render_hierarchy(){ void ui_layer::set_cursor(int const Mode) { - glfwSetInputMode(m_window, GLFW_CURSOR, Mode); + // no window, no cursor to hide + if (m_window != nullptr) + glfwSetInputMode(m_window, GLFW_CURSOR, Mode); m_cursorvisible = Mode != GLFW_CURSOR_DISABLED; } diff --git a/network/chat.cpp b/network/chat.cpp new file mode 100644 index 00000000..094f42ff --- /dev/null +++ b/network/chat.cpp @@ -0,0 +1,94 @@ +/* +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/chat.h" + +#include "utilities/Globals.h" +#include "utilities/Logs.h" + +namespace +{ + +std::deque g_log; +uint64_t g_serial{0}; + +} // namespace + +std::string network::tidy_chat(std::string const &Raw) +{ + std::string text; + text.reserve(Raw.size()); + + bool space{false}; + for (char const character : Raw) + { + unsigned char const value = (unsigned char)character; + // a newline would let one line pretend to be several, including one that looks + // like it came from somebody else + if (value < 0x20 || value == 0x7f) + continue; + + if (character == ' ') + { + space = !text.empty(); + continue; + } + + if (space) + { + text += ' '; + space = false; + } + + text += character; + if (text.size() >= CHAT_LENGTH_LIMIT) + break; + } + + return text; +} + +void network::note_chat(PeerId const Author, std::string const &Text) +{ + auto const text = tidy_chat(Text); + if (text.empty()) + return; + + chat_line line; + line.author = Author; + line.name = Peers.name_of(Author); + line.text = text; + line.time = Global.fTimeAngleDeg; + + g_log.emplace_back(line); + while (g_log.size() > CHAT_HISTORY) + g_log.pop_front(); + + ++g_serial; + + // on a machine running without a window this is the only place the conversation shows + WriteLog("chat: <" + line.name + "> " + line.text, logtype::net); +} + +std::deque const &network::chat_log() +{ + return g_log; +} + +uint64_t network::chat_serial() +{ + return g_serial; +} + +void network::clear_chat() +{ + g_log.clear(); + g_serial = 0; +} diff --git a/network/chat.h b/network/chat.h new file mode 100644 index 00000000..c9d1c490 --- /dev/null +++ b/network/chat.h @@ -0,0 +1,51 @@ +/* +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 +#include + +#include "network/entities.h" + +namespace network +{ + +// the longest thing anybody may say at once. a line, not an essay - and a cap the server +// applies to what it receives rather than trusting the sender to have applied it +constexpr size_t CHAT_LENGTH_LIMIT = 200; +// how much of the conversation is kept +constexpr size_t CHAT_HISTORY = 100; + +struct chat_line +{ + PeerId author{PEER_NONE}; + // resolved when the line arrives: a peer may be gone by the time it is read back + std::string name; + std::string text; + // simulation clock, for the timestamp in front of the line + double time{0.0}; +}; + +// puts a line in the log, whoever it came from. also writes it to the ordinary log, which +// is what a headless server has instead of a chat window +void note_chat(PeerId Author, std::string const &Text); + +std::deque const &chat_log(); + +// how many lines have arrived since the session started. the panel watches this to know +// when to scroll, and the rest of the ui to know there is something new +uint64_t chat_serial(); + +void clear_chat(); + +// trims and caps a line, and says whether anything worth sending is left +std::string tidy_chat(std::string const &Raw); + +} // namespace network diff --git a/network/entities.cpp b/network/entities.cpp index 270eeab5..00da09c9 100644 --- a/network/entities.cpp +++ b/network/entities.cpp @@ -22,6 +22,146 @@ namespace network { entity_registry Entities; +peer_registry Peers; + +namespace +{ + +// long enough for a name worth having, short enough not to wreck a lobby column +constexpr size_t NICKNAME_LIMIT = 24; + +std::string tidy_name(std::string const &Raw) +{ + std::string name; + name.reserve(Raw.size()); + + bool space{false}; + for (char const character : Raw) + { + // control characters and tabs have no business in a name that goes on somebody + // else's screen, and a run of spaces is one space + unsigned char const value = (unsigned char)character; + if (value < 0x20 || value == 0x7f) + continue; + + if (character == ' ') + { + space = !name.empty(); + continue; + } + + if (space) + { + name += ' '; + space = false; + } + + name += character; + if (name.size() >= NICKNAME_LIMIT) + break; + } + + return name; +} + +} // namespace + +std::string local_nickname() +{ + auto name = tidy_name(Global.multiplayer_nickname); + if (!name.empty()) + return name; + + // nothing configured: whoever is logged in will do, and failing that nothing at all - + // the registry has a stand-in for that case + for (char const *variable : {"EU07_NICKNAME", "USERNAME", "USER", "LOGNAME"}) + { + char const *value = std::getenv(variable); + if (value == nullptr) + continue; + name = tidy_name(value); + if (!name.empty()) + return name; + } + + return std::string(); +} + +bool peer_registry::taken(std::string const &Name, PeerId const Except) const +{ + for (auto const &entry : m_names) + { + if (entry.first == Except) + continue; + if (entry.second == Name) + return true; + } + return false; +} + +std::string peer_registry::assign(PeerId const Peer, std::string const &Requested) +{ + std::string name = tidy_name(Requested); + + if (name.empty()) + name = (Peer == PEER_HOST ? std::string("host") : "player " + std::to_string(Peer)); + + // two people called Marcin are two people, and the lobby has to say which is which + if (taken(name, Peer)) + { + std::string const base = name; + for (int suffix = 2; suffix < 100; ++suffix) + { + name = base + " (" + std::to_string(suffix) + ")"; + if (!taken(name, Peer)) + break; + } + } + + m_names[Peer] = name; + + return name; +} + +void peer_registry::forget(PeerId const Peer) +{ + m_names.erase(Peer); +} + +void peer_registry::adopt(std::vector const &Roster) +{ + m_names.clear(); + for (auto const &entry : Roster) + m_names[entry.id] = entry.name; +} + +std::string peer_registry::name_of(PeerId const Peer) const +{ + auto const lookup = m_names.find(Peer); + if (lookup != m_names.end()) + return lookup->second; + + if (Peer == PEER_HOST) + return "host"; + if (Peer == PEER_NONE) + return "nobody"; + + return "player " + std::to_string(Peer); +} + +std::vector peer_registry::roster() const +{ + std::vector entries; + entries.reserve(m_names.size()); + for (auto const &entry : m_names) + entries.emplace_back(peer_entry{entry.first, entry.second}); + return entries; +} + +void peer_registry::clear() +{ + m_names.clear(); +} PeerId allocate_peer_id() { diff --git a/network/entities.h b/network/entities.h index d743a8fb..754fc190 100644 --- a/network/entities.h +++ b/network/entities.h @@ -107,6 +107,40 @@ private: // session wide vehicle registry. stays empty outside of multiplayer extern entity_registry Entities; +// what a participant is called. a peer id is what the protocol addresses, but "player 4" +// is no use to anybody reading a lobby or a chat line +struct peer_entry +{ + PeerId id{PEER_NONE}; + std::string name; +}; + +class peer_registry +{ + public: + // takes a name a peer asked for and makes it fit to show: trimmed, control characters + // out, capped in length, and made unique within the session + std::string assign(PeerId Peer, std::string const &Requested); + void forget(PeerId Peer); + // a client replaces its whole idea of who is in the session with what the server sent + void adopt(std::vector const &Roster); + // the name, or a readable stand-in for a peer nobody has told us about yet + std::string name_of(PeerId Peer) const; + std::vector roster() const; + void clear(); + + private: + bool taken(std::string const &Name, PeerId Except) const; + + std::unordered_map m_names; +}; + +// who is in the session. the host keeps the authoritative copy and hands it to everybody +extern peer_registry Peers; + +// the name this peer would like to be known by, cleaned up but not yet made unique +std::string local_nickname(); + // hands out ids for connecting peers; PEER_HOST is reserved for the local participant PeerId allocate_peer_id(); diff --git a/network/manager.cpp b/network/manager.cpp index e12c996d..94398a2c 100644 --- a/network/manager.cpp +++ b/network/manager.cpp @@ -14,6 +14,7 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Globals.h" #include "network/statehash.h" #include "network/snapshot.h" +#include "network/chat.h" network::server_manager::server_manager() { @@ -191,6 +192,44 @@ void network::server_manager::publish_state() srv->push_message(msg); } +void network::server_manager::broadcast_chat(PeerId const author, const std::string &text) +{ + chat_message msg; + msg.peer = author; + msg.text = text; + + for (auto srv : servers) + srv->push_message(msg); +} + +void network::server_manager::publish_roster() +{ + for (auto srv : servers) + srv->publish_roster(); +} + +void network::manager::say(const std::string &text) +{ + auto const line = tidy_chat(text); + if (line.empty()) + return; + + if (servers) + { + // the host is the one who decides what was said, so it says it and passes it on + note_chat(Global.network_peer_id, line); + servers->broadcast_chat(Global.network_peer_id, line); + return; + } + + if (client) + { + // a client waits to hear itself back from the server, so that everybody sees the + // conversation in the same order + client->send_chat(line); + } +} + void network::manager::request_resync(uint64_t tick, uint64_t state_hash) { if (client) diff --git a/network/manager.h b/network/manager.h index 2c6d7719..7bec989c 100644 --- a/network/manager.h +++ b/network/manager.h @@ -27,6 +27,10 @@ namespace network // 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); + // hands a line of chat to every peer + void broadcast_chat(PeerId author, const std::string &text); + // tells the peers who is taking part + void publish_roster(); private: // how many frames may pass between two unconditional broadcasts of the roster @@ -73,5 +77,9 @@ namespace network // our world drifted away from the authoritative one; ask to be corrected void request_resync(uint64_t tick, uint64_t state_hash); + + // something the local participant said. the host says it to everybody at once, a + // client hands it to the host to say on its behalf + void say(const std::string &text); }; } diff --git a/network/message.cpp b/network/message.cpp index 127b2f18..0781cabe 100644 --- a/network/message.cpp +++ b/network/message.cpp @@ -8,6 +8,7 @@ void network::client_hello::serialize(std::ostream &stream) const sn_utils::ls_uint32(stream, start_packet); sn_utils::s_str(stream, app_version); sn_utils::ls_uint64(stream, session_token); + sn_utils::s_str(stream, nickname); } void network::client_hello::deserialize(std::istream &stream) @@ -16,6 +17,43 @@ void network::client_hello::deserialize(std::istream &stream) start_packet = sn_utils::ld_uint32(stream); app_version = sn_utils::d_str(stream); session_token = sn_utils::ld_uint64(stream); + nickname = sn_utils::d_str(stream); +} + +void network::peer_roster::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, (uint32_t)peers.size()); + for (auto const &entry : peers) + { + sn_utils::ls_uint32(stream, entry.id); + sn_utils::s_str(stream, entry.name); + } +} + +void network::peer_roster::deserialize(std::istream &stream) +{ + auto const count = sn_utils::ld_uint32(stream); + peers.clear(); + peers.reserve(count); + for (uint32_t i = 0; i < count; ++i) + { + peer_entry entry; + entry.id = sn_utils::ld_uint32(stream); + entry.name = sn_utils::d_str(stream); + peers.emplace_back(std::move(entry)); + } +} + +void network::chat_message::serialize(std::ostream &stream) const +{ + sn_utils::ls_uint32(stream, peer); + sn_utils::s_str(stream, text); +} + +void network::chat_message::deserialize(std::istream &stream) +{ + peer = sn_utils::ld_uint32(stream); + text = sn_utils::d_str(stream); } void network::server_reject::serialize(std::ostream &stream) const @@ -307,6 +345,10 @@ std::shared_ptr network::deserialize_message(std::istream &str msg = std::make_shared(); else if (type == message::REQUEST_RESYNC) msg = std::make_shared(); + else if (type == message::PEER_ROSTER) + msg = std::make_shared(); + else if (type == message::CHAT) + 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 68ebaaff..2e7f0d8c 100644 --- a/network/message.h +++ b/network/message.h @@ -24,6 +24,8 @@ struct message CLIENT_READY, SNAPSHOT, REQUEST_RESYNC, + PEER_ROSTER, + CHAT, TYPE_MAX }; @@ -49,6 +51,9 @@ struct client_hello : public message // player who dropped out gets their seat and their peer id back. it is a reconnect // token, not a credential - it says "this is the same session", nothing more uint64_t session_token{ 0 }; + // what the player would like to be called. a request, not a fact: the server tidies + // it up, makes it unique and hands back what it settled on + std::string nickname; }; // sent by the server instead of SERVER_HELLO when the client cannot join; @@ -140,6 +145,31 @@ struct request_resync : public message virtual void deserialize(std::istream &stream) override; }; +// who is taking part, and what each of them is called. rebroadcast whenever somebody +// joins or leaves, so that no peer has to guess at a name +struct peer_roster : public message +{ + peer_roster() : message(PEER_ROSTER) {} + + std::vector peers; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; +}; + +// something a participant said. going up it carries only the text - who said it is +// decided by the connection it arrived on, never by what the sender put in the field +struct chat_message : public message +{ + chat_message() : message(CHAT) {} + + uint32_t peer{ PEER_NONE }; + std::string text; + + virtual void serialize(std::ostream &stream) const override; + 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 { diff --git a/network/network.cpp b/network/network.cpp index 1242189b..50ae0248 100644 --- a/network/network.cpp +++ b/network/network.cpp @@ -1,6 +1,7 @@ #include "stdafx.h" #include "network/network.h" #include "network/snapshot.h" +#include "network/chat.h" #include "network/message.h" #include "utilities/Logs.h" #include "scene/sn_utils.h" @@ -81,6 +82,7 @@ void network::connection::connected() msg.start_packet = packet_counter; msg.app_version = Global.asVersion; msg.session_token = Global.network_session_token; + msg.nickname = local_nickname(); send_message(msg); } } @@ -117,20 +119,35 @@ void report_rejected_command(network::PeerId Peer, user_command Command, network void network::server::prune_clients() { + bool dropped{false}; + 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); + WriteLog("net: " + Peers.name_of((*it)->peer_id) + " (peer " + std::to_string((*it)->peer_id) + ") disconnected", logtype::net); // the seat is held for a while in case the peer comes straight back. a // vehicle keeps running for whoever is left on board either way; it only // falls back to the AI once the crew really is empty Crews.suspend_peer((*it)->peer_id); + Peers.forget((*it)->peer_id); + dropped = true; } it = clients.erase(it); continue; } it++; } + + if (dropped) { + // whoever is left learns who is left. sent from here rather than through + // push_message, which is what called us + peer_roster msg; + msg.peers = Peers.roster(); + for (auto const &client : clients) { + if (client->state == connection::ACTIVE) + client->send_message(msg); + } + } } void network::server::push_delta(const frame_info &msg) @@ -153,6 +170,13 @@ void network::server::push_message(const message &msg) } } +void network::server::publish_roster() +{ + peer_roster msg; + msg.peers = Peers.roster(); + push_message(msg); +} + command_queue::commands_map network::server::pop_commands() { command_queue::commands_map map(client_commands_queue); @@ -203,6 +227,8 @@ void network::server::handle_message(std::shared_ptr conn, const mes reply.session_token = cmd.session_token; reply.peer_id = resolve_peer_identity(reply.session_token); conn->peer_id = reply.peer_id; + // what the player asked to be called, tidied up and made unique within the session + auto const name = Peers.assign(conn->peer_id, cmd.nickname); // the peer now goes and loads the scenario. it gets the state of the world as a // snapshot when it reports back, instead of replaying the session frame by frame conn->state = connection::AWAITING_READY; @@ -210,7 +236,7 @@ void network::server::handle_message(std::shared_ptr conn, const mes conn->send_message(reply); - WriteLog("net: peer " + std::to_string(conn->peer_id) + " connected, build \"" + cmd.app_version + WriteLog("net: peer " + std::to_string(conn->peer_id) + " connected as \"" + name + "\", build \"" + cmd.app_version + "\", scenario \"" + Global.SceneryFile + "\"", logtype::net); } else if (msg.type == message::CLIENT_READY) { @@ -227,6 +253,11 @@ void network::server::handle_message(std::shared_ptr conn, const mes return; } + // who is taking part, so that the crew section of the snapshot has names to show + peer_roster people; + people.peers = Peers.roster(); + conn->send_message(people); + // the roster first, so that the crew section of the snapshot resolves to names vehicle_list roster; roster.vehicles = Entities.entries(); @@ -241,12 +272,31 @@ void network::server::handle_message(std::shared_ptr conn, const mes // from here on the peer receives the live stream; there is no backlog to catch up on conn->state = connection::ACTIVE; + // and everybody already in the session learns who just walked in + publish_roster(); + WriteLog("net: snapshot sent to peer " + std::to_string(conn->peer_id) + " at tick " + std::to_string(reply.tick), logtype::net); // and it still has to learn who is where Crews.mark_all_pending(); } + else if (msg.type == message::CHAT) { + const auto& cmd = dynamic_cast(msg); + + // the sender is the connection this arrived on, never the field in the packet: + // otherwise anybody could put words in anybody's mouth + auto const text = tidy_chat(cmd.text); + if (text.empty()) + return; + + chat_message relay; + relay.peer = conn->peer_id; + relay.text = text; + + note_chat(relay.peer, relay.text); + push_message(relay); + } else if (msg.type == message::SNAPSHOT) { const auto& cmd = dynamic_cast(msg); @@ -474,6 +524,19 @@ void network::client::send_leave(NetworkEntityId entity_id) conn->send_message(msg); } +void network::client::send_chat(const std::string &text) +{ + if (!conn || conn->state != connection::ACTIVE) + return; + + chat_message msg; + // the peer field is filled in by the server from the connection; whatever we put here + // is ignored, which is the point + msg.peer = Global.network_peer_id; + msg.text = text; + conn->send_message(msg); +} + void network::client::handle_message(std::shared_ptr conn, const message &msg) { if (msg.type >= message::TYPE_MAX) diff --git a/network/network.h b/network/network.h index bcd1332b..3e9dd580 100644 --- a/network/network.h +++ b/network/network.h @@ -101,6 +101,8 @@ namespace network // sends an out of band message to every active peer. unlike push_delta this is not // written to the backbuffer, so it does not become part of the replayed history void push_message(const message &msg); + // tells every peer who is taking part and what each of them is called + void publish_roster(); command_queue::commands_map pop_commands(); }; @@ -165,6 +167,7 @@ namespace network // lobby requests; the server is the one that decides void send_claim(NetworkEntityId entity_id); void send_leave(NetworkEntityId entity_id); + void send_chat(const std::string &text); int get_frame_counter() { return resume_frame_counter; } diff --git a/utilities/Globals.cpp b/utilities/Globals.cpp index 8ced123b..024aa2d4 100644 --- a/utilities/Globals.cpp +++ b/utilities/Globals.cpp @@ -914,6 +914,13 @@ bool global_settings::ConfigParseNetwork(cParser& Parser, const std::string& tok return true; } + if (token == "multiplayer.nickname") + { + Parser.getTokens(1, false); + Parser >> multiplayer_nickname; + return true; + } + return false; } @@ -1829,6 +1836,7 @@ global_settings::export_as_text( std::ostream &Output ) const { } export_as_text( Output, "multiplayer.sync.running", multiplayer_sync_running ); export_as_text( Output, "multiplayer.sync.stop", multiplayer_sync_stop ); + export_as_text( Output, "multiplayer.nickname", multiplayer_nickname ); export_as_text( Output, "execonexit", exec_on_exit ); } diff --git a/utilities/Globals.h b/utilities/Globals.h index ce04b68e..75b8cb3b 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -380,6 +380,11 @@ struct global_settings { // consistent whatever each player has in their ini float multiplayer_sync_running = 1.5f; float multiplayer_sync_stop = 0.2f; + // what this player would like to be called in a session. empty falls back to the + // account name, and failing that to "player " + std::string multiplayer_nickname; + // no window, no renderer and nobody in a cab: the executable as a dedicated server + bool headless = false; // the scenario has finished loading and the world can be worked with. this is not the // same as simulation::is_ready, which only goes up once the player has a cab bool simulation_loaded = false; diff --git a/widgets/chat_panel.cpp b/widgets/chat_panel.cpp new file mode 100644 index 00000000..1192ad68 --- /dev/null +++ b/widgets/chat_panel.cpp @@ -0,0 +1,111 @@ +/* +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 "widgets/chat_panel.h" + +#include "application/application.h" +#include "network/chat.h" +#include "network/entities.h" +#include "utilities/Globals.h" +#include "utilities/translation.h" + +namespace +{ + +// keeps the key that opens the panel from ending up in the line being typed +int filter_input(ImGuiInputTextCallbackData *Data) +{ + if (Data->EventChar == '`' || Data->EventChar == '~') + return 1; + return 0; +} + +} // namespace + +ui::chat_panel::chat_panel() + : ui_panel(STR_C("Chat"), false) +{ + // both bounds have to be set, or imgui gets a maximum below the minimum + size_min = {380, 200}; + size_max = {900, 600}; +} + +void ui::chat_panel::focus_input() +{ + m_focusrequested = true; +} + +void ui::chat_panel::submit() +{ + std::string const text(m_input.data()); + m_input.fill('\0'); + + if (text.empty()) + return; + + Application.say(text); +} + +void ui::chat_panel::render_contents() +{ + if (!network::is_multiplayer()) + { + ImGui::TextUnformatted(STR_C("This is not a multiplayer session.")); + return; + } + + auto const &log = network::chat_log(); + + // the conversation, with room left underneath for the box it is typed into + float const lineheight = ImGui::GetTextLineHeightWithSpacing(); + ImGui::BeginChild("chatlog", ImVec2(0.0f, -(lineheight + ImGui::GetStyle().ItemSpacing.y * 2.0f)), false, + ImGuiWindowFlags_HorizontalScrollbar); + + for (auto const &line : log) + { + bool const own{line.author == Global.network_peer_id}; + ImGui::TextColored(own ? ImVec4(0.6f, 0.85f, 1.0f, 1.0f) : ImVec4(1.0f, 0.85f, 0.55f, 1.0f), "%s:", line.name.c_str()); + ImGui::SameLine(); + ImGui::TextWrapped("%s", line.text.c_str()); + } + + // follow the conversation, but only when something new has actually arrived - dragging + // the scrollbar back to read something should not be undone every frame + if (network::chat_serial() != m_lastserial) + { + m_lastserial = network::chat_serial(); + ImGui::SetScrollHereY(1.0f); + } + + ImGui::EndChild(); + + if (m_focusrequested) + { + ImGui::SetKeyboardFocusHere(); + m_focusrequested = false; + } + + ImGui::PushItemWidth(-1.0f); + if (ImGui::InputText("##chatinput", m_input.data(), m_input.size(), + ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackCharFilter, filter_input)) + { + submit(); + // staying in the box is what anybody expects after sending a line + m_focusrequested = true; + } + ImGui::PopItemWidth(); + + // while the box has the keyboard, imgui swallows the key that opened the panel, so + // there has to be another way out of it + if (ImGui::IsKeyPressed(ImGui::GetIO().KeyMap[ImGuiKey_Escape])) + { + is_open = false; + } +} diff --git a/widgets/chat_panel.h b/widgets/chat_panel.h new file mode 100644 index 00000000..656b02de --- /dev/null +++ b/widgets/chat_panel.h @@ -0,0 +1,38 @@ +/* +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 "application/uilayer.h" + +namespace ui +{ + +// what the participants of a session are saying to one another. opened and closed with the +// key left of 1, and while it is open the text box holds the keyboard, so typing "d" is +// typing "d" rather than opening the doors of whatever you happen to be driving +class chat_panel : public ui_panel +{ + public: + chat_panel(); + + void render_contents() override; + + // called when the panel is opened, so that the cursor lands in the text box + void focus_input(); + + private: + void submit(); + + std::array m_input{}; + bool m_focusrequested{false}; + uint64_t m_lastserial{0}; +}; + +} // namespace ui diff --git a/widgets/multiplayer_lobby.cpp b/widgets/multiplayer_lobby.cpp index 4e3d9261..49251d69 100644 --- a/widgets/multiplayer_lobby.cpp +++ b/widgets/multiplayer_lobby.cpp @@ -51,10 +51,8 @@ std::string ui::multiplayer_lobby_panel::describe_crew(network::NetworkEntityId 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); + description += network::Peers.name_of(peer); } return description; @@ -71,7 +69,24 @@ void ui::multiplayer_lobby_panel::render_contents() return; } - ImGui::Text("%s: %s, peer %u", STR_C("Session"), host ? (client ? "host + client" : "host") : "client", Global.network_peer_id); + ImGui::Text("%s: %s, %s (peer %u)", STR_C("Session"), host ? (client ? "host + client" : "host") : "client", + network::Peers.name_of(Global.network_peer_id).c_str(), Global.network_peer_id); + + auto const roster = network::Peers.roster(); + if (roster.size() > 1) + { + std::string others; + for (auto const &entry : roster) + { + if (entry.id == Global.network_peer_id) + continue; + if (!others.empty()) + others += ", "; + others += entry.name; + } + if (!others.empty()) + ImGui::TextDisabled("%s: %s", STR_C("Also here"), others.c_str()); + } if (!Global.SceneryFile.empty()) ImGui::TextDisabled("%s", Global.SceneryFile.c_str());