Index: binaries/data/mods/dedicatedserver/dedicatedserver/DedicatedServer.js =================================================================== --- /dev/null +++ binaries/data/mods/dedicatedserver/dedicatedserver/DedicatedServer.js @@ -0,0 +1,135 @@ +var timer; +var lastStanza; +var g_PlayerAssignments; + +function escapeText(text) +{ + return text.replace(/\\/g, "\\\\").replace(/\[/g, "\\["); +} + +/** + * Merge players by team to remove duplicate Team entries, thus reducing the packet size of the lobby report. + */ +function playerDataToStringifiedTeamList(playerData) +{ + let teamList = {}; + + for (let pData of playerData) + { + let team = pData.Team === undefined ? -1 : pData.Team; + if (!teamList[team]) + teamList[team] = []; + teamList[team].push(pData); + delete teamList[team].Team; + } + + return escapeText(JSON.stringify(teamList)); +} + +/** + * Send a list of playernames and distinct between players and observers. + * Don't send teams, AIs or anything else until the game was started. + */ +function formatClientsForStanza() +{ + let connectedPlayers = 0; + let playerData = []; + + for (let guid in g_PlayerAssignments) + { + let pData = { "Name": g_PlayerAssignments[guid].name }; + + if (g_PlayerAssignments[guid].player != -1) + ++connectedPlayers; + else + pData.Team = "observer"; + + playerData.push(pData); + } + + return { + "list": playerDataToStringifiedTeamList(playerData), + "connectedPlayers": connectedPlayers + }; +} + +function sendStanza() +{ + warn("Sending Stanza"); + warn(uneval(lastStanza)); + Engine.SendRegisterGame(lastStanza); + timer = undefined; +} + +function onSettingsUpdate(settings) +{ + try + { + let clients = formatClientsForStanza(); + + let stanza = { + "name": Engine.LobbyGetNick() + " dedicated server", + "hostUsername": Engine.LobbyGetNick(), + "hostJID": "", // Overwritten by C++, placeholder. + "mapName": settings.initAttribs.map, + // TODO: if the map name was always up-to-date we wouldn't need the mapcache here. + "niceMapName": settings.initAttribs.settings.Name, + "mapSize": settings.initAttribs.settings.Size, + "mapType": settings.initAttribs.mapType, + "victoryConditions": Array.from(settings.initAttribs.settings.VictoryConditions).join(","), + "nbp": clients.connectedPlayers, + "maxnbp": settings.initAttribs.settings.PlayerData.length, + "players": clients.list, + // TODO: fetch host mods. + "mods": "[]", + "hasPassword": "", + }; + + // Only send the stanza if one of these properties changed + if (lastStanza && Object.keys(stanza).every(prop => lastStanza[prop] == stanza[prop])) + return; + + lastStanza = stanza; + if (!timer) + timer = setTimeout(() => sendStanza(), 1000); + } + catch(err) + { + error(uneval(err)); + } +} + +Engine.LoadScript("dedicatedserver/timer.js"); + +function Tick() +{ + updateTimers(); + while (true) + { + let message = Engine.LobbyGuiPollNewMessages() + if (!message) + break; + + warn("Lobby:" + uneval(message)); + } + + while (true) + { + let message = Engine.PollNetworkClient() + if (!message) + break; + + warn("Message: " + message.type); + if (message.type === "start") + Engine.MarkLoadFinished(); + else if (message.type === "players") + g_PlayerAssignments = message.newAssignments; + else if (message.type === "gamesetup" && (message.data.type === "update" || message.data.type === "initial-update")) + onSettingsUpdate(message.data) + else + warn(uneval(message)); + } + + // False means DON'T QUIT + return false; +} Index: binaries/data/mods/dedicatedserver/dedicatedserver/timer.js =================================================================== --- /dev/null +++ binaries/data/mods/dedicatedserver/dedicatedserver/timer.js @@ -0,0 +1,68 @@ +var g_TimerID = 0; +var g_Timers = {}; +var g_Time = Date.now(); + +/** + * Set a timeout to call func() after 'delay' msecs. + * func: function to call + * delay: delay in ms + * Returns an id that can be passed to clearTimeout. + */ +function setTimeout(func, delay) +{ + var id = ++g_TimerID; + g_Timers[id] = [g_Time + delay, func]; + return id; +} + +/** + * deletes a timer + * id: of the timer + */ +function clearTimeout(id) +{ + delete g_Timers[id]; +} + +/** +* alters an function call +* id: of the timer +* func: function to call +*/ +function setNewTimerFunction(id, func) +{ + if (id in g_Timers) + g_Timers[id][1] = func; +} + +/** + * If you want to use timers, then you must call this function regularly + * (e.g. in a Tick handler) + */ +function updateTimers() +{ + g_Time = Date.now(); + + // Collect the timers that need to run + // (We do this in two stages to avoid deleting from the timer list while + // we're in the middle of iterating through it) + var run = []; + for (let id in g_Timers) + if (g_Timers[id][0] <= g_Time) + run.push(id); + + for (let id of run) + { + let t = g_Timers[id]; + if (!t) + continue; // an earlier timer might have cancelled this one, so skip it + + try { + t[1](); + } catch (e) { + var stack = e.stack.trimRight().replace(/^/mg, ' '); // indent the stack trace + error(sprintf("Error in timer: %(error)s", { "error": e }) + "\n" + stack + "\n"); + } + delete g_Timers[id]; + } +} Index: binaries/data/mods/dedicatedserver/mod.json =================================================================== --- /dev/null +++ binaries/data/mods/dedicatedserver/mod.json @@ -0,0 +1,8 @@ +{ + "name": "dedicatedserver", + "version": "0.0.26", + "label": "Dedicated Server", + "url": "https://play0ad.com", + "description": "Enable 0 A.D.'s dedicated server mode", + "dependencies": [] +} Index: binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js =================================================================== --- binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js +++ binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js @@ -30,6 +30,17 @@ switch (attribs.multiplayerGameType) { + case "dedicated": + { + if (!Engine.HasXmppClient()) + { + switchSetupPage("pageJoin"); + break; + } + if (startJoinFromLobby(attribs.name, attribs.hostJID, attribs.pass, attribs.secret)) + switchSetupPage("pageConnecting"); + break; + } case "join": { if (!Engine.HasXmppClient()) @@ -424,7 +435,7 @@ /** * Connect via the lobby. */ -function startJoinFromLobby(playername, hostJID, password) +function startJoinFromLobby(playername, hostJID, password, secret) { if (!Engine.HasXmppClient()) { @@ -439,7 +450,7 @@ try { - Engine.StartNetworkJoinLobby(playername + (g_UserRating ? " (" + g_UserRating + ")" : ""), hostJID, password); + Engine.StartNetworkJoinLobby(playername + (g_UserRating ? " (" + g_UserRating + ")" : ""), hostJID, password, secret); } catch (e) { Index: source/main.cpp =================================================================== --- source/main.cpp +++ source/main.cpp @@ -44,6 +44,7 @@ #include "ps/CConsole.h" #include "ps/CLogger.h" #include "ps/ConfigDB.h" +#include "ps/DedicatedServer.h" #include "ps/Filesystem.h" #include "ps/Game.h" #include "ps/Globals.h" @@ -526,6 +527,7 @@ const bool isNonVisualReplay = args.Has("replay"); const bool isNonVisual = args.Has("autostart-nonvisual"); const bool isUsingRLInterface = args.Has("rl-interface"); + const bool isDedicated = args.Has("dedicated"); const OsPath replayFile( isVisualReplay ? args.Get("replay-visual") : @@ -667,12 +669,19 @@ g_Mods.UpdateAvailableMods(modInterface); } - if (isNonVisual) + if (isNonVisual || isDedicated) { InitNonVisual(args); if (isUsingRLInterface) StartRLInterface(args); + // This runs its own inner-loop. + if (isDedicated) + { + RunDedicatedServer(args); + g_Shutdown = ShutdownType::Quit; + } + while (g_Shutdown == ShutdownType::None) { if (isUsingRLInterface) Index: source/network/NetClient.h =================================================================== --- source/network/NetClient.h +++ source/network/NetClient.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -68,6 +68,7 @@ * The game must exist for the lifetime of this object. */ CNetClient(CGame* game); + CNetClient(CGame* game, const ScriptInterface& scriptInterface); virtual ~CNetClient(); @@ -300,6 +301,9 @@ void PostPlayerAssignmentsToScript(); CGame *m_Game; + + const ScriptInterface& m_ScriptInterface; + CStrW m_UserName; CStr m_HostJID; Index: source/network/NetClient.cpp =================================================================== --- source/network/NetClient.cpp +++ source/network/NetClient.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ #include "NetClientTurnManager.h" #include "NetMessage.h" #include "NetSession.h" +#include "NetServer.h" #include "lib/byte_order.h" #include "lib/external_libraries/enet.h" @@ -83,8 +84,11 @@ CStr m_InitAttributes; }; -CNetClient::CNetClient(CGame* game) : +CNetClient::CNetClient(CGame* game) : CNetClient(game, game->GetSimulation2()->GetScriptInterface()) {} + +CNetClient::CNetClient(CGame* game, const ScriptInterface& scriptInterface) : m_Session(NULL), + m_ScriptInterface(scriptInterface), m_UserName(L"anonymous"), m_HostID((u32)-1), m_ClientTurnManager(NULL), m_Game(game), m_LastConnectionCheck(0), @@ -92,7 +96,8 @@ m_ServerPort(0), m_Rejoin(false) { - m_Game->SetTurnManager(NULL); // delete the old local turn manager so we don't accidentally use it + if (m_Game) + m_Game->SetTurnManager(NULL); // delete the old local turn manager so we don't accidentally use it void* context = this; @@ -422,7 +427,7 @@ const ScriptInterface& CNetClient::GetScriptInterface() { - return m_Game->GetSimulation2()->GetScriptInterface(); + return m_ScriptInterface; } void CNetClient::PostPlayerAssignmentsToScript() @@ -620,7 +625,7 @@ } CLoadedGameMessage loaded; - loaded.m_CurrentTurn = m_ClientTurnManager->GetCurrentTurn(); + loaded.m_CurrentTurn = m_ClientTurnManager ? m_ClientTurnManager->GetCurrentTurn() : 0; SendMessage(&loaded); } @@ -672,7 +677,14 @@ if (message->m_Flags & PS_NETWORK_FLAG_REQUIRE_LOBBYAUTH) { - if (g_XmppClient && !client->m_HostJID.empty()) + if (!client->m_Game) + { + // This is a fake, headless client for the netserver. + ENSURE(g_NetServer); + LOGMESSAGE("sending fake Auth"); + g_NetServer->OnLobbyAuth("Fake Observer", client->m_GUID); + } + else if (g_XmppClient && !client->m_HostJID.empty()) g_XmppClient->SendIqLobbyAuth(client->m_HostJID, client->m_GUID); else { @@ -804,17 +816,21 @@ if (client->m_PlayerAssignments.find(client->m_GUID) != client->m_PlayerAssignments.end()) player = client->m_PlayerAssignments[client->m_GUID].m_PlayerID; - client->m_ClientTurnManager = new CNetClientTurnManager( + if (client->m_Game) + client->m_ClientTurnManager = new CNetClientTurnManager( *client->m_Game->GetSimulation2(), *client, client->m_HostID, client->m_Game->GetReplayLogger()); // Parse init attributes. - const ScriptInterface& scriptInterface = client->m_Game->GetSimulation2()->GetScriptInterface(); + const ScriptInterface& scriptInterface = client->GetScriptInterface(); ScriptRequest rq(scriptInterface); JS::RootedValue initAttribs(rq.cx); Script::ParseJSON(rq, message->m_InitAttributes, &initAttribs); - client->m_Game->SetPlayerID(player); - client->m_Game->StartGame(&initAttribs, ""); + if (client->m_Game) + { + client->m_Game->SetPlayerID(player); + client->m_Game->StartGame(&initAttribs, ""); + } client->PushGuiMessage("type", "start", "initAttributes", initAttribs); @@ -967,7 +983,8 @@ // All players have loaded the game - start running the turn manager // so that the game begins - client->m_Game->SetTurnManager(client->m_ClientTurnManager); + if (client->m_Game) + client->m_Game->SetTurnManager(client->m_ClientTurnManager); client->PushGuiMessage( "type", "netstatus", @@ -987,6 +1004,9 @@ CNetClient* client = static_cast(context); CNetMessage* message = static_cast(event->GetParamRef()); + if (!client->m_ClientTurnManager) + return true; + if (message) { if (message->GetType() == NMT_SIMULATION_COMMAND) Index: source/network/scripting/JSInterface_Network.cpp =================================================================== --- source/network/scripting/JSInterface_Network.cpp +++ source/network/scripting/JSInterface_Network.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -165,7 +165,7 @@ * This is needed to not force server to share it's public ip with all potential clients in the lobby. * XmppClient will also handle logic after receiving the answer. */ -void StartNetworkJoinLobby(const CStrW& playerName, const CStr& hostJID, const CStr& password) +void StartNetworkJoinLobby(const CStrW& playerName, const CStr& hostJID, const CStr& password, const std::string& secret) { ENSURE(!!g_XmppClient); ENSURE(!g_NetClient); @@ -178,6 +178,7 @@ g_NetClient->SetUserName(playerName); g_NetClient->SetHostJID(hostJID); g_NetClient->SetGamePassword(hashedPass); + g_NetClient->SetControllerSecret(secret); g_NetClient->SetupConnectionViaLobby(); } Index: source/ps/DedicatedServer.h =================================================================== --- /dev/null +++ source/ps/DedicatedServer.h @@ -0,0 +1,25 @@ +/* Copyright (C) 2022 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#ifndef INCLUDED_DEDICATEDSERVER +#define INCLUDED_DEDICATEDSERVER + +class CmdLineArgs; + +void RunDedicatedServer(const CmdLineArgs& args); + +#endif // INCLUDED_DEDICATEDSERVER Index: source/ps/DedicatedServer.cpp =================================================================== --- /dev/null +++ source/ps/DedicatedServer.cpp @@ -0,0 +1,176 @@ +/* Copyright (C) 2022 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#include "precompiled.h" + +#include "DedicatedServer.h" + +#include "lib/input.h" +#include "lib/timer.h" +#include "lobby/IXmppClient.h" +#include "lobby/scripting/JSInterface_Lobby.h" +#include "network/NetClient.h" +#include "network/NetServer.h" +#include "network/StunClient.h" +#include "network/scripting/JSInterface_Network.h" +#include "ps/ConfigDB.h" +#include "ps/Game.h" +#include "ps/GUID.h" +#include "ps/GameSetup/CmdLineArgs.h" +#include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/ScriptContext.h" +#include "scriptinterface/ScriptInterface.h" +#include "simulation2/Simulation2.h" + +#include "lib/external_libraries/libsdl.h" + +#include +#include + +namespace +{ +void LoadFinished() +{ + if (!g_NetClient) + return; + g_NetClient->LoadFinished(); +} +void LoadScript(const ScriptInterface& scriptInterface, CStr file) +{ + if (!scriptInterface.LoadGlobalScriptFile(file)) + ScriptException::Raise(ScriptRequest(scriptInterface), "Could not load file %s", file.c_str()); +} +} + +void RunDedicatedServer(const CmdLineArgs& args) +{ + ScriptInterface scriptInterface("Engine", "DedicatedServer", g_ScriptContext); + + { + ScriptRequest rq(scriptInterface); + JSI_Lobby::RegisterScriptFunctions(rq); + JSI_Network::RegisterScriptFunctions(rq); + ScriptFunction::Register<&LoadFinished>(rq, "MarkLoadFinished"); + ScriptFunction::Register<&LoadScript>(rq, "LoadScript"); + } + + if (!scriptInterface.LoadGlobalScriptFile("dedicatedserver/DedicatedServer.js")) + { + LOGERROR("Could not load dedicated server script"); + return; + } + + std::string lobbyPassword; + std::string lobbyLogin; + std::string lobbyRoom; + CFG_GET_VAL("lobby.password", lobbyPassword); + CFG_GET_VAL("lobby.login", lobbyLogin); + CFG_GET_VAL("lobby.room", lobbyRoom); + + LOGWARNING("Attempting to connect to lobby as %s", lobbyLogin); + g_XmppClient = IXmppClient::create(&scriptInterface, + lobbyLogin, + lobbyPassword, + lobbyRoom, + lobbyLogin); + g_XmppClient->connect(); + + g_NetServer = new CNetServer(true); + + u16 port = 20595; + if (!g_NetServer->SetupConnection(port)) + { + SAFE_DELETE(g_NetServer); + SAFE_DELETE(g_XmppClient); + return; + } + + g_NetServer->SetConnectionDataViaSTUN(); + + CStr ip = g_NetServer->GetPublicIp(); + + // Generate a secret to identify the host client. + std::string secret = ps_generate_guid(); + g_NetServer->SetControllerSecret(secret); + + double time = timer_Time(); + + constexpr double LOBBY_CONNECTION_TIMEOUT = 30.0; + while (g_XmppClient && !g_XmppClient->isConnected()) + { + g_XmppClient->recv(); + + if (timer_Time() - time > LOBBY_CONNECTION_TIMEOUT) + { + LOGERROR("Could not connect to 0 A.D. Lobby. Please make sure the configuration is correct"); + SAFE_DELETE(g_NetServer); + SAFE_DELETE(g_XmppClient); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + + LOGWARNING("Hosting at %s:%i, JID %s, secret %s", ip, port, g_XmppClient->GetJID(), secret); + LOGWARNING("Engine.PushGuiPage('page_gamesetup_mp.xml', { 'multiplayerGameType': 'dedicated', 'name': '%s', 'pass': '', 'hostJID': '%s', 'secret': '%s' });", lobbyLogin, g_XmppClient->GetJID(), secret); + + g_NetClient = new CNetClient(nullptr, scriptInterface); + g_NetClient->SetUserName(L"Fake observer"); + g_NetClient->SetHostJID("Fake observer"); + g_NetClient->SetGamePassword(""); + g_NetClient->SetControllerSecret(""); + g_NetClient->SetupServerData("127.0.0.1", port, false); + if (!g_NetClient->SetupConnection(nullptr)) + LOGWARNING("failed to connect client"); + + while (g_NetServer) + { + if (g_XmppClient) + g_XmppClient->recv(); + + g_NetClient->Poll(); + + { + ScriptRequest rq(scriptInterface); + JS::RootedValue glob(rq.cx, rq.globalValue()); + bool shouldQuit = false; + ScriptFunction::Call(rq, glob, "Tick", shouldQuit); + if (shouldQuit) + break; + } + + SDL_Event_ ev; + while (in_poll_event(&ev)) + in_dispatch_event(&ev); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + }; + + // Always attempt to unregister the game. + g_XmppClient->SendIqUnregisterGame(); + + g_NetClient->DestroyConnection(); + SAFE_DELETE(g_NetClient); + SAFE_DELETE(g_NetServer); + + if (g_XmppClient) + g_XmppClient->recv(); + + SAFE_DELETE(g_XmppClient); + + return; +}