From b90280855f43ef42159dc689fcf59a0d1ec78ce2 Mon Sep 17 00:00:00 2001 From: phosit Date: Sat, 31 Aug 2024 10:54:56 +0200 Subject: [PATCH] Multiplayer saved games Enables to save multiplayer games. When the savegame is loaded, the settings are frozen (except the non-AI-player assignment settings). --- .../mods/public/gamesettings/GameSettings.js | 17 ++- .../gamesettings/attributes/PlayerName.js | 12 +- .../public/gui/credits/texts/programming.json | 1 + .../Controllers/GameSettingsController.js | 49 ++++---- .../PlayerAssignmentsController.js | 10 +- .../Pages/AIConfigPage/AIConfigPage.js | 9 +- .../AIConfigPage/AIGameSettingControl.js | 4 +- .../GameSettings/GameSettingControl.js | 10 +- .../GameSettings/GameSettingControlManager.js | 9 +- .../GameSettings/PerPlayer/AIConfigButton.js | 9 +- .../PerPlayer/Dropdowns/PlayerAssignment.js | 26 ++++- .../PerPlayer/Dropdowns/PlayerCiv.js | 2 +- .../GameSettings/PerPlayer/PlayerName.js | 5 +- .../GameSettings/Single/Buttons/MapBrowser.js | 8 +- .../Single/Checkboxes/LastManStanding.js | 5 + .../GameSettings/Single/Checkboxes/Rating.js | 6 +- .../Pages/GameSetupPage/GameSetupPage.js | 15 +-- .../Pages/GameSetupPage/GameSetupPage.xml | 12 +- .../Panels/Buttons/ResetCivsButton.js | 9 +- .../Panels/Buttons/ResetTeamsButton.js | 9 +- .../Panels/Buttons/SavedGameLabel.js | 8 ++ .../Panels/Buttons/SavedGameLabel.xml | 14 +++ .../Pages/GameSetupPage/Panels/MapPreview.js | 19 +++- .../Pages/MapBrowserPage/MapBrowserPage.js | 4 +- .../mods/public/gui/gamesetup/SetupWindow.js | 15 ++- .../public/gui/gamesetup_mp/gamesetup_mp.js | 64 +++++++---- .../public/gui/gamesetup_mp/gamesetup_mp.xml | 5 - .../gui/lobby/LobbyPage/Buttons/HostButton.js | 10 +- .../gui/lobby/LobbyPage/GameButtons.xml | 14 +++ .../public/gui/lobby/LobbyPage/LobbyPage.js | 5 +- .../public/gui/lobby/LobbyPage/LobbyPage.xml | 6 +- .../mods/public/gui/pregame/MainMenuItems.js | 21 ++-- source/network/NetClient.cpp | 106 ++++++++++++------ source/network/NetClient.h | 11 ++ source/network/NetMessage.cpp | 4 + source/network/NetMessages.h | 8 +- source/network/NetServer.cpp | 63 +++++++++-- source/network/NetServer.h | 26 ++++- .../network/scripting/JSInterface_Network.cpp | 29 ++++- source/ps/SavedGame.cpp | 36 ++++-- source/ps/SavedGame.h | 23 ++-- source/ps/scripting/JSInterface_SavedGame.cpp | 23 ++-- 42 files changed, 542 insertions(+), 199 deletions(-) create mode 100644 binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.js create mode 100644 binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.xml create mode 100644 binaries/data/mods/public/gui/lobby/LobbyPage/GameButtons.xml diff --git a/binaries/data/mods/public/gamesettings/GameSettings.js b/binaries/data/mods/public/gamesettings/GameSettings.js index c6f432a113..2b2a221315 100644 --- a/binaries/data/mods/public/gamesettings/GameSettings.js +++ b/binaries/data/mods/public/gamesettings/GameSettings.js @@ -11,7 +11,7 @@ */ class GameSettings { - init(mapCache) + init(mapCache, savegameID) { if (!mapCache) mapCache = new MapCache(); @@ -19,6 +19,10 @@ class GameSettings "value": mapCache, }); + Object.defineProperty(this, "savegameID", { + "value": savegameID, + }); + // Load all possible civ data - don't presume that some will be available. Object.defineProperty(this, "civData", { "value": loadCivData(false, false), @@ -40,6 +44,15 @@ class GameSettings if (this[comp].init) this[comp].init(); + if (!savegameID) + return this; + + const initAttributes = Engine.LoadSavedGameMetadata(savegameID).initAttributes; + + // Remove the gaia entry. + initAttributes.settings.PlayerData.splice(0, 1); + this.fromInitAttributes(initAttributes); + return this; } @@ -142,7 +155,7 @@ class GameSettings // NB: for multiplayer support, the clients must be listening to "start" net messages. if (this.isNetworked) - Engine.StartNetworkGame(this.finalizedAttributes, storeReplay); + Engine.StartNetworkGame(this.savegameID, this.finalizedAttributes, storeReplay); else Engine.StartGame(this.finalizedAttributes, playerAssignments.local.player, storeReplay); } diff --git a/binaries/data/mods/public/gamesettings/attributes/PlayerName.js b/binaries/data/mods/public/gamesettings/attributes/PlayerName.js index 1d524ab864..2135145615 100644 --- a/binaries/data/mods/public/gamesettings/attributes/PlayerName.js +++ b/binaries/data/mods/public/gamesettings/attributes/PlayerName.js @@ -7,6 +7,8 @@ */ GameSettings.prototype.Attributes.PlayerName = class PlayerName extends GameSetting { + randomPicked = false; + init() { // NB: watchers aren't auto-triggered when modifying array elements. @@ -21,6 +23,9 @@ GameSettings.prototype.Attributes.PlayerName = class PlayerName extends GameSett attribs.settings.PlayerData = []; while (attribs.settings.PlayerData.length < this.values.length) attribs.settings.PlayerData.push({}); + if (this.isSavedGame && !this.randomPicked) + return; + for (let i in this.values) if (this.values[i]) attribs.settings.PlayerData[i].Name = this.values[i]; @@ -87,7 +92,7 @@ GameSettings.prototype.Attributes.PlayerName = class PlayerName extends GameSett const names = this.settings.civData[civ].AINames; const remainingNames = names.filter(name => !AIPlayerNamesList.includes(name)); const chosenName = pickRandom(remainingNames.length ? remainingNames : names); - + // Avoid translating AI names if the game is networked, so all players see and refer to // English names instead of names in the language of the host. const translatedCountLabel = this.settings.isNetworked ? this.CountLabel : translate(this.CountLabel); @@ -98,7 +103,7 @@ GameSettings.prototype.Attributes.PlayerName = class PlayerName extends GameSett count++; return count; }, 0); - + AIPlayerNamesList.push(chosenName); this.values[i] = !duplicateNameCount ? translatedChosenName : @@ -108,7 +113,10 @@ GameSettings.prototype.Attributes.PlayerName = class PlayerName extends GameSett }); } if (picked) + { + this.randomPicked = true; this.trigger("values"); + } return picked; } diff --git a/binaries/data/mods/public/gui/credits/texts/programming.json b/binaries/data/mods/public/gui/credits/texts/programming.json index 1eb465dcc1..8f2907c16a 100644 --- a/binaries/data/mods/public/gui/credits/texts/programming.json +++ b/binaries/data/mods/public/gui/credits/texts/programming.json @@ -190,6 +190,7 @@ { "nick": "MattDoerksen", "name": "Matt Doerksen" }, { "nick": "mattlott", "name": "Matt Lott" }, { "nick": "maveric", "name": "Anton Protko" }, + { "nick": "mbusy", "name": "Maxime Busy" }, { "nick": "Micnasty", "name": "Travis Gorkin" }, { "name": "MikoĊ‚aj \"Bajter\" Korcz" }, { "nick": "mimo" }, diff --git a/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js b/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js index e0c400730e..99ce4990fa 100644 --- a/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js +++ b/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js @@ -3,7 +3,7 @@ */ class GameSettingsController { - constructor(setupWindow, netMessages, playerAssignmentsController, mapCache) + constructor(setupWindow, netMessages, playerAssignmentsController, mapCache, isSavedGame) { this.setupWindow = setupWindow; this.mapCache = mapCache; @@ -25,7 +25,7 @@ class GameSettingsController this.loadingChangeHandlers = new Set(); this.settingsLoadedHandlers = new Set(); - setupWindow.registerLoadHandler(this.onLoad.bind(this)); + setupWindow.registerLoadHandler(this.onLoad.bind(this, isSavedGame)); setupWindow.registerGetHotloadDataHandler(this.onGetHotloadData.bind(this)); setupWindow.registerClosePageHandler(this.onClose.bind(this)); @@ -75,29 +75,32 @@ class GameSettingsController this.settingsLoadedHandlers.add(handler); } - onLoad(initData, hotloadData) + onLoad(isSavedGame, initData, hotloadData) { - // This initial settings parsing in wrapped in a try-catch because it can fail unexpectedly, - // and particularly could fail with mods that change persistent settings, so this is - // difficult to fully fix from the gameSettings code. - // Also include hotloaded data because that can also fail and having to restart isn't very useful. - try { - if (hotloadData) - this.parseSettings(hotloadData.initAttributes); - else if (g_IsController && (initData?.gameSettings || this.persistentMatchSettings.enabled)) - { - // Allow opting-in to persistence when sending initial data (though default off) - if (initData?.gameSettings) - this.persistentMatchSettings.enabled = !!initData.gameSettings?.usePersistence; - const settings = initData?.gameSettings || this.persistentMatchSettings.loadFile(); - if (settings) - this.parseSettings(settings); + if (!isSavedGame) + { + // This initial settings parsing in wrapped in a try-catch because it can fail unexpectedly, + // and particularly could fail with mods that change persistent settings, so this is + // difficult to fully fix from the gameSettings code. + // Also include hotloaded data because that can also fail and having to restart isn't very useful. + try { + if (hotloadData) + this.parseSettings(hotloadData.initAttributes); + else if (g_IsController && (initData?.gameSettings || this.persistentMatchSettings.enabled)) + { + // Allow opting-in to persistence when sending initial data (though default off) + if (initData?.gameSettings) + this.persistentMatchSettings.enabled = !!initData.gameSettings?.usePersistence; + const settings = initData?.gameSettings || this.persistentMatchSettings.loadFile(); + if (settings) + this.parseSettings(settings); + } + } catch(err) { + error("There was an error loading game settings. You may need to disable persistent match settings."); + warn(err?.toString() ?? uneval(err)); + if (err.stack) + warn(err.stack) } - } catch(err) { - error("There was an error loading game settings. You may need to disable persistent match settings."); - warn(err?.toString() ?? uneval(err)); - if (err.stack) - warn(err.stack) } // If the new settings led to AI & players conflict, remove the AI. diff --git a/binaries/data/mods/public/gui/gamesetup/Controllers/PlayerAssignmentsController.js b/binaries/data/mods/public/gui/gamesetup/Controllers/PlayerAssignmentsController.js index 771fab92e5..264bda24b8 100644 --- a/binaries/data/mods/public/gui/gamesetup/Controllers/PlayerAssignmentsController.js +++ b/binaries/data/mods/public/gui/gamesetup/Controllers/PlayerAssignmentsController.js @@ -3,7 +3,7 @@ */ class PlayerAssignmentsController { - constructor(setupWindow, netMessages) + constructor(setupWindow, netMessages, isSavedGame) { this.clientJoinHandlers = new Set(); this.clientLeaveHandlers = new Set(); @@ -35,7 +35,7 @@ class PlayerAssignmentsController setupWindow.registerGetHotloadDataHandler(this.onGetHotloadData.bind(this)); netMessages.registerNetMessageHandler("players", this.onPlayerAssignmentMessage.bind(this)); - this.registerClientJoinHandler(this.onClientJoin.bind(this)); + this.registerClientJoinHandler(this.onClientJoin.bind(this, isSavedGame)); } registerPlayerAssignmentsChangeHandler(handler) @@ -93,10 +93,12 @@ class PlayerAssignmentsController * On client join, try to assign them to a free slot. * (This is called before g_PlayerAssignments is updated). */ - onClientJoin(newGUID, newAssignments) + onClientJoin(isSavedGame, newGUID, newAssignments) { - if (!g_IsController || newAssignments[newGUID].player != -1) + if (!g_IsController || newAssignments[newGUID].player !== -1 || isSavedGame) + { return; + } // Assign the client (or only buddies if prefered) to a free slot if (newGUID != Engine.GetPlayerGUID()) diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIConfigPage.js b/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIConfigPage.js index c3d1ab862e..691d59f760 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIConfigPage.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIConfigPage.js @@ -7,7 +7,7 @@ class AIGameSettingControls SetupWindowPages.AIConfigPage = class { - constructor(setupWindow) + constructor(setupWindow, isSavedGame) { this.gameSettingsController = setupWindow.controls.gameSettingsController; @@ -18,7 +18,8 @@ SetupWindowPages.AIConfigPage = class for (let name of this.AIGameSettingControlOrder) this.AIGameSettingControls[name] = - new AIGameSettingControls[name](this, undefined, undefined, setupWindow); + new AIGameSettingControls[name](this, undefined, undefined, setupWindow, + isSavedGame); this.aiDescription = new AIDescription(this, setupWindow); @@ -38,12 +39,12 @@ SetupWindowPages.AIConfigPage = class return this.row++; } - openPage(playerIndex) + openPage(playerIndex, enabled) { this.playerIndex = playerIndex; for (let handler of this.openPageHandlers) - handler(playerIndex); + handler(playerIndex, enabled); this.aiConfigPage.hidden = false; } diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIGameSettingControl.js b/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIGameSettingControl.js index 6a9bb19cbf..4c64ff316e 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIGameSettingControl.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/AIConfigPage/AIGameSettingControl.js @@ -1,8 +1,8 @@ class AIGameSettingControlDropdown extends GameSettingControlDropdown { - onOpenPage(playerIndex) + onOpenPage(playerIndex, enabled) { - this.setEnabled(true); + this.setEnabled(enabled); this.playerIndex = playerIndex; this.render(); } diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js index f87111114b..04ae897a55 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js @@ -13,12 +13,13 @@ */ class GameSettingControl /* extends Profilable /* Uncomment to profile controls without hassle. */ { - constructor(gameSettingControlManager, category, playerIndex, setupWindow) + constructor(gameSettingControlManager, category, playerIndex, setupWindow, isSavedGame) { // Store arguments { this.category = category; this.playerIndex = playerIndex; + this.isSavedGame = isSavedGame; this.setupWindow = setupWindow; this.gameSettingsController = setupWindow.controls.gameSettingsController; @@ -55,6 +56,9 @@ class GameSettingControl /* extends Profilable /* Uncomment to profile controls if (this.onPlayerAssignmentsChange) this.playerAssignmentsController.registerPlayerAssignmentsChangeHandler(this.onPlayerAssignmentsChange.bind(this)); + + if (isSavedGame) + this.setEnabled(this.EnabledWhenSavedGame); } setTitle(titleCaption) @@ -79,7 +83,7 @@ class GameSettingControl /* extends Profilable /* Uncomment to profile controls setEnabled(enabled) { - this.enabled = enabled; + this.enabled = enabled && (!this.isSavedGame || this.EnabledWhenSavedGame); this.updateVisibility(); } @@ -138,6 +142,8 @@ class GameSettingControl /* extends Profilable /* Uncomment to profile controls } } +GameSettingControl.prototype.EnabledWhenSavedGame = false; + GameSettingControl.prototype.TitleCaptionFormat = translateWithContext("Title for specific setting", "%(setting)s:"); diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControlManager.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControlManager.js index dbad5303e0..44a461773e 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControlManager.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControlManager.js @@ -11,7 +11,7 @@ class GameSettingControls */ class GameSettingControlManager { - constructor(setupWindow) + constructor(setupWindow, isSavedGame) { this.setupWindow = setupWindow; @@ -24,17 +24,18 @@ class GameSettingControlManager for (let name in GameSettingControls) this.gameSettingControls[name] = new GameSettingControls[name]( - this, getCategory(name), undefined, setupWindow); + this, getCategory(name), undefined, setupWindow, isSavedGame); for (let victoryCondition of g_VictoryConditions) this.gameSettingControls[victoryCondition.Name] = new VictoryConditionCheckbox( - victoryCondition, this, getCategory(victoryCondition.Name), undefined, setupWindow); + victoryCondition, this, getCategory(victoryCondition.Name), undefined, + setupWindow, isSavedGame); this.playerSettingControlManagers = Array.from( new Array(g_MaxPlayers), (value, playerIndex) => - new PlayerSettingControlManager(playerIndex, setupWindow)); + new PlayerSettingControlManager(playerIndex, setupWindow, isSavedGame)); } getNextRow(name) diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/AIConfigButton.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/AIConfigButton.js index b7f7841982..3f611190cc 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/AIConfigButton.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/AIConfigButton.js @@ -5,6 +5,9 @@ PlayerSettingControls.AIConfigButton = class AIConfigButton extends GameSettingC super(...args); this.aiConfigButton = Engine.GetGUIObjectByName("aiConfigButton[" + this.playerIndex + "]"); + this.aiConfigButton.onPress = () => { + this.setupWindow.pages.AIConfigPage.openPage(this.playerIndex, this.enabled); + }; g_GameSettings.playerAI.watch(() => this.render(), ["values"]); // Save little performance by not reallocating every call @@ -12,12 +15,6 @@ PlayerSettingControls.AIConfigButton = class AIConfigButton extends GameSettingC this.render(); } - onLoad() - { - let aiConfigPage = this.setupWindow.pages.AIConfigPage; - this.aiConfigButton.onPress = aiConfigPage.openPage.bind(aiConfigPage, this.playerIndex); - } - render() { this.aiConfigButton.hidden = !g_GameSettings.playerAI.get(this.playerIndex); diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerAssignment.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerAssignment.js index 4afe2ad703..0c9873d398 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerAssignment.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerAssignment.js @@ -30,6 +30,16 @@ PlayerSettingControls.PlayerAssignment = class PlayerAssignment extends GameSett // Build the initial list of values with undefined & AI clients. this.rebuildList(); + const savedAI = this.isSavedGame && g_GameSettings.playerAI.get(this.playerIndex); + + if (savedAI) + { + this.setSelectedValue(savedAI.bot); + this.setEnabled(false); + } + else + this.rebuildList(); + g_GameSettings.playerAI.watch(() => this.render(), ["values"]); g_GameSettings.playerCount.watch((_, oldNb) => this.OnPlayerNbChange(oldNb), ["nbPlayers"]); } @@ -103,9 +113,13 @@ PlayerSettingControls.PlayerAssignment = class PlayerAssignment extends GameSett // TODO: this particular bit is done for each row, which is unnecessarily inefficient. this.playerItems = sortGUIDsByPlayerID().map( this.clientItemFactory.createItem.bind(this.clientItemFactory)); + + // If loading a saved game clients and unassigned players can't be replaced by a AI. Don't show + // the AIs in the dropdown. + const disableAI = this.isSavedGame && !g_GameSettings.playerAI.get(this.playerIndex); this.values = prepareForDropdown([ ...this.playerItems, - ...this.aiItems, + ...disableAI ? [] : this.aiItems, this.unassignedItem ]); @@ -122,7 +136,8 @@ PlayerSettingControls.PlayerAssignment = class PlayerAssignment extends GameSett this.gameSettingsController, this.playerAssignmentsController, this.playerIndex, - this.values.Value[itemIdx]); + this.values.Value[itemIdx], + this.isSavedGame); } getAutocompleteEntries() @@ -131,6 +146,8 @@ PlayerSettingControls.PlayerAssignment = class PlayerAssignment extends GameSett } }; +PlayerSettingControls.PlayerAssignment.prototype.EnabledWhenSavedGame = true; + PlayerSettingControls.PlayerAssignment.prototype.Tooltip = translate("Select player."); @@ -151,7 +168,8 @@ PlayerSettingControls.PlayerAssignment.prototype.AutocompleteOrder = 100; }; } - onSelectionChange(gameSettingsController, playerAssignmentsController, playerIndex, guidToAssign) + onSelectionChange(gameSettingsController, playerAssignmentsController, playerIndex, + guidToAssign, isSavedGame) { let sourcePlayer = g_PlayerAssignments[guidToAssign].player - 1; if (sourcePlayer >= 0) @@ -161,7 +179,7 @@ PlayerSettingControls.PlayerAssignment.prototype.AutocompleteOrder = 100; if (ai) g_GameSettings.playerAI.swap(sourcePlayer, playerIndex); // Swap color + civ as well - this allows easy reorganizing of player order. - if (g_GameSettings.map.type !== "scenario") + if (g_GameSettings.map.type !== "scenario" && !isSavedGame) { g_GameSettings.playerCiv.swap(sourcePlayer, playerIndex); g_GameSettings.playerColor.swap(sourcePlayer, playerIndex); diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerCiv.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerCiv.js index 561c692117..393e2864f0 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerCiv.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/Dropdowns/PlayerCiv.js @@ -27,7 +27,7 @@ PlayerSettingControls.PlayerCiv = class PlayerCiv extends GameSettingControlDrop rebuild() { - const isLocked = g_GameSettings.playerCiv.locked[this.playerIndex]; + const isLocked = g_GameSettings.playerCiv.locked[this.playerIndex] || this.isSavedGame; if (this.wasLocked !== isLocked) { this.wasLocked = isLocked; diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/PlayerName.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/PlayerName.js index fc584e655a..c88331a7a7 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/PlayerName.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/PerPlayer/PlayerName.js @@ -5,6 +5,7 @@ PlayerSettingControls.PlayerName = class PlayerName extends GameSettingControl constructor(...args) { super(...args); + g_GameSettings.playerName.isSavedGame = this.isSavedGame; this.playerName = Engine.GetGUIObjectByName("playerName[" + this.playerIndex + "]"); g_GameSettings.playerCount.watch(() => this.render(), ["nbPlayers"]); @@ -29,8 +30,8 @@ PlayerSettingControls.PlayerName = class PlayerName extends GameSettingControl render() { - let name = this.guid ? g_PlayerAssignments[this.guid].name : - g_GameSettings.playerName.values[this.playerIndex]; + let name = this.guid && this.isSavedGame ? + g_PlayerAssignments[this.guid].name : g_GameSettings.playerName.values[this.playerIndex]; if (g_IsNetworked) { diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js index ddc90c5176..1c5dd9efe9 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js @@ -4,6 +4,12 @@ GameSettingControls.MapBrowser = class MapBrowser extends GameSettingControlButt { super(...args); + if (this.isSavedGame) + { + this.setHidden(true); + return; + } + this.button.tooltip = colorizeHotkey(this.HotkeyTooltip, this.HotkeyConfig); Engine.SetGlobalHotkey(this.HotkeyConfig, "Press", this.onPress.bind(this)); } @@ -25,7 +31,7 @@ GameSettingControls.MapBrowser = class MapBrowser extends GameSettingControlButt onPress() { - this.setupWindow.pages.MapBrowserPage.openPage(); + this.setupWindow.pages.MapBrowserPage.openPage(this.enabled); } }; diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/LastManStanding.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/LastManStanding.js index 2cee31dd7b..49f76f2952 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/LastManStanding.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/LastManStanding.js @@ -7,6 +7,11 @@ GameSettingControls.LastManStanding = class LastManStanding extends GameSettingC g_GameSettings.map.watch(() => this.render(), ["type"]); } + onSettingsLoaded() + { + this.render(); + } + render() { // Always display this, so that players are aware that there is this gamemode diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/Rating.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/Rating.js index 47b4a56fa0..22c85d9879 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/Rating.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Checkboxes/Rating.js @@ -6,7 +6,11 @@ GameSettingControls.Rating = class Rating extends GameSettingControlCheckbox // The availability of rated games is not a GUI concern, unlike most other // potentially available settings. - g_GameSettings.rating.watch(() => this.render(), ["enabled", "available"]); + if (this.isSavedGame) + g_GameSettings.rating.enabled = false; + else + g_GameSettings.rating.watch(() => this.render(), ["enabled", "available"]); + this.render(); } diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.js index 5c4560161e..443b72dfc6 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.js @@ -3,21 +3,22 @@ */ SetupWindowPages.GameSetupPage = class { - constructor(setupWindow) + constructor(setupWindow, isSavedGame) { Engine.ProfileStart("GameSetupPage"); // This class instance owns all game setting GUI controls such as dropdowns and checkboxes visible in this page. - this.gameSettingControlManager = new GameSettingControlManager(setupWindow); + this.gameSettingControlManager = new GameSettingControlManager(setupWindow, isSavedGame); // These classes manage GUI buttons. { let startGameButton = new StartGameButton(setupWindow); let readyButton = new ReadyButton(setupWindow); this.panelButtons = { - "cancelButton": new CancelButton(setupWindow, startGameButton, readyButton), "civInfoButton": new CivInfoButton(), "lobbyButton": new LobbyButton(), + "savedGameLabel": new SavedGameLabel(isSavedGame), + "cancelButton": new CancelButton(setupWindow, startGameButton, readyButton), "readyButton": readyButton, "startGameButton": startGameButton }; @@ -31,13 +32,13 @@ SetupWindowPages.GameSetupPage = class this.panels = { "chatPanel": new ChatPanel(setupWindow, this.gameSettingControlManager, gameSettingsPanel), - "gameSettingWarning": new GameSettingWarning(setupWindow, this.panelButtons.cancelButton), + "gameSettingWarning": new GameSettingWarning(setupWindow), "gameDescription": new GameDescription(setupWindow, gameSettingTabs), "gameSettingsPanel": gameSettingsPanel, "gameSettingsTabs": gameSettingTabs, - "mapPreview": new MapPreview(setupWindow), - "resetCivsButton": new ResetCivsButton(setupWindow), - "resetTeamsButton": new ResetTeamsButton(setupWindow), + "mapPreview": new MapPreview(setupWindow, isSavedGame), + "resetCivsButton": new ResetCivsButton(setupWindow, isSavedGame), + "resetTeamsButton": new ResetTeamsButton(setupWindow, isSavedGame), "soundNotification": new SoundNotification(setupWindow), "tipsPanel": new TipsPanel(gameSettingsPanel) }; diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.xml b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.xml index 7b525684da..0388d9b8fa 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.xml +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSetupPage.xml @@ -67,23 +67,27 @@ - + - + - + + + + + - + diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetCivsButton.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetCivsButton.js index 996bd36ab8..ca226a08be 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetCivsButton.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetCivsButton.js @@ -1,6 +1,6 @@ class ResetCivsButton { - constructor(setupWindow) + constructor(setupWindow, isSavedGame) { this.gameSettingsController = setupWindow.controls.gameSettingsController; @@ -8,12 +8,15 @@ class ResetCivsButton this.civResetButton.tooltip = this.Tooltip; this.civResetButton.onPress = this.onPress.bind(this); - g_GameSettings.map.watch(() => this.render(), ["type"]); + if (isSavedGame) + this.civResetButton.hidden = true; + else + g_GameSettings.map.watch(() => this.render(), ["type"]); } render() { - this.civResetButton.hidden = g_GameSettings.map.type == "scenario" || !g_IsController; + this.civResetButton.hidden = g_GameSettings.map.type === "scenario" || !g_IsController; } onPress() diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetTeamsButton.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetTeamsButton.js index 77086a5272..e6be2b70db 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetTeamsButton.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ResetTeamsButton.js @@ -1,6 +1,6 @@ class ResetTeamsButton { - constructor(setupWindow) + constructor(setupWindow, isSavedGame) { this.gameSettingsController = setupWindow.controls.gameSettingsController; @@ -8,12 +8,15 @@ class ResetTeamsButton this.teamResetButton.tooltip = this.Tooltip; this.teamResetButton.onPress = this.onPress.bind(this); - g_GameSettings.map.watch(() => this.render(), ["type"]); + if (isSavedGame) + this.teamResetButton.hidden = true; + else + g_GameSettings.map.watch(() => this.render(), ["type"]); } render() { - this.teamResetButton.hidden = g_GameSettings.map.type == "scenario" || !g_IsController; + this.teamResetButton.hidden = g_GameSettings.map.type === "scenario" || !g_IsController; } onPress() diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.js new file mode 100644 index 0000000000..c3278e3668 --- /dev/null +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.js @@ -0,0 +1,8 @@ +class SavedGameLabel +{ + constructor(isSavedGame) + { + if (isSavedGame) + Engine.GetGUIObjectByName("savedGameLabel").hidden = false; + } +} diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.xml b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.xml new file mode 100644 index 0000000000..a4c6ff9e3a --- /dev/null +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.xml @@ -0,0 +1,14 @@ + + diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/MapPreview.js b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/MapPreview.js index 169bc5fef6..99c1ceaba1 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/MapPreview.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/MapPreview.js @@ -1,6 +1,6 @@ class MapPreview { - constructor(setupWindow) + constructor(setupWindow, isSavedGame) { this.setupWindow = setupWindow; this.gameSettingsController = setupWindow.controls.gameSettingsController; @@ -8,16 +8,27 @@ class MapPreview this.mapInfoName = Engine.GetGUIObjectByName("mapInfoName"); this.mapPreview = Engine.GetGUIObjectByName("mapPreview"); - this.mapPreview.onMouseLeftPress = this.onPress.bind(this); // TODO: Why does onPress not work? CGUI.cpp seems to support it - this.mapPreview.tooltip = this.Tooltip; + if (isSavedGame) + { + // Delay the settings registration handler until we have the map cache. + setupWindow.controls.gameSettingsController.registerSettingsLoadedHandler(() => { + this.renderName(); + this.renderPreview(); + }); + return; + } + + // TODO: Why does onPress not work? CGUI.cpp seems to support it. + this.mapPreview.onMouseLeftPress = this.onPress.bind(this, isSavedGame); + this.mapPreview.tooltip = this.Tooltip; g_GameSettings.map.watch(() => this.renderName(), ["map"]); g_GameSettings.mapPreview.watch(() => this.renderPreview(), ["value"]); } onPress() { - this.setupWindow.pages.MapBrowserPage.openPage(); + this.setupWindow.pages.MapBrowserPage.openPage(true); } renderName() diff --git a/binaries/data/mods/public/gui/gamesetup/Pages/MapBrowserPage/MapBrowserPage.js b/binaries/data/mods/public/gui/gamesetup/Pages/MapBrowserPage/MapBrowserPage.js index dd34f5c0bf..3347c729e0 100644 --- a/binaries/data/mods/public/gui/gamesetup/Pages/MapBrowserPage/MapBrowserPage.js +++ b/binaries/data/mods/public/gui/gamesetup/Pages/MapBrowserPage/MapBrowserPage.js @@ -25,9 +25,9 @@ SetupWindowPages.MapBrowserPage = class extends MapBrowser this.gameSettingsController.setNetworkInitAttributes(); } - openPage() + openPage(enabled) { - super.openPage(g_IsController); + super.openPage(g_IsController && enabled); this.controls.MapFiltering.select( this.gameSettingsController.guiData.mapFilter.filter, diff --git a/binaries/data/mods/public/gui/gamesetup/SetupWindow.js b/binaries/data/mods/public/gui/gamesetup/SetupWindow.js index 8a9caed17a..dda689b971 100644 --- a/binaries/data/mods/public/gui/gamesetup/SetupWindow.js +++ b/binaries/data/mods/public/gui/gamesetup/SetupWindow.js @@ -25,13 +25,20 @@ class SetupWindow if (initData?.backPage) this.backPage = initData.backPage; + const savedGame = initData?.savedGame; + const isSavedGame = !!savedGame; + const mapCache = new MapCache(); - g_GameSettings = new GameSettings().init(mapCache); + g_GameSettings = new GameSettings(); + g_GameSettings.init(mapCache, g_IsController ? savedGame : null); + let netMessages = new NetMessages(); let mapFilters = new MapFilters(mapCache); - let playerAssignmentsController = new PlayerAssignmentsController(this, netMessages); - let gameSettingsController = new GameSettingsController(this, netMessages, playerAssignmentsController, mapCache); + let playerAssignmentsController = + new PlayerAssignmentsController(this, netMessages, isSavedGame); + let gameSettingsController = new GameSettingsController(this, netMessages, + playerAssignmentsController, mapCache, isSavedGame); let readyController = new ReadyController(netMessages, gameSettingsController, playerAssignmentsController); const lobbyGameRegistrationController = g_IsController && Engine.HasXmppClient() && new LobbyGameRegistrationController(initData, this, netMessages, mapCache, playerAssignmentsController); @@ -50,7 +57,7 @@ class SetupWindow // These are the pages within the setup window that may use the controls defined above this.pages = {}; for (let name in SetupWindowPages) - this.pages[name] = new SetupWindowPages[name](this); + this.pages[name] = new SetupWindowPages[name](this, isSavedGame); netMessages.registerNetMessageHandler("netwarn", addNetworkWarning); setTimeout(displayGamestateNotifications, 1000); diff --git a/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js b/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js index 98a4d07da5..667d86be95 100644 --- a/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js +++ b/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.js @@ -68,6 +68,9 @@ function init(attribs) error("Unrecognised multiplayer game type: " + attribs.multiplayerGameType); break; } + + Engine.GetGUIObjectByName("multiplayerPages").onTick = onTick.bind(null, attribs.loadSavedGame); + Engine.GetGUIObjectByName("continueButton").onPress = confirmSetup.bind(null, attribs.loadSavedGame); } function cancelSetup() @@ -104,7 +107,7 @@ function confirmPassword() switchSetupPage("pageConnecting"); } -function confirmSetup() +function confirmSetup(loadSavedGame) { if (!Engine.GetGUIObjectByName("pageJoin").hidden) { @@ -137,8 +140,11 @@ function confirmSetup() let hostPlayerName = Engine.GetGUIObjectByName("hostPlayerName").caption; let hostPassword = Engine.GetGUIObjectByName("hostPassword").caption; - if (startHost(hostPlayerName, hostServerName, getValidPort(hostPort), hostPassword)) + if (startHost(hostPlayerName, hostServerName, getValidPort(hostPort), hostPassword, + loadSavedGame)) + { switchSetupPage("pageConnecting"); + } } } @@ -150,12 +156,12 @@ function startConnectionStatus(type) Engine.GetGUIObjectByName("connectionStatus").caption = translate("Connecting to server..."); } -function onTick() +function onTick(loadSavedGame) { if (!g_IsConnecting) return; - pollAndHandleNetworkClient(); + pollAndHandleNetworkClient(loadSavedGame); } function getConnectionFailReason(reason) @@ -182,7 +188,7 @@ function reportConnectionFail(reason) ); } -function pollAndHandleNetworkClient() +function pollAndHandleNetworkClient(loadSavedGame) { while (true) { @@ -277,17 +283,8 @@ function pollAndHandleNetworkClient() break; case "authenticated": - if (message.rejoining) - { - Engine.GetGUIObjectByName("connectionStatus").caption = translate("Game has already started, rejoining..."); - g_IsRejoining = true; - return; // we'll process the game setup messages in the next tick - } - Engine.SwitchGuiPage("page_gamesetup.xml", { - "serverName": g_ServerName, - "hasPassword": g_ServerHasPassword - }); - return; // don't process any more messages - leave them for the game GUI loop + handleAuthenticated(message, loadSavedGame); + return; case "disconnected": cancelSetup(); @@ -311,6 +308,34 @@ function pollAndHandleNetworkClient() } } +async function handleAuthenticated(message, loadSavedGame) +{ + if (message.rejoining) + { + Engine.GetGUIObjectByName("connectionStatus").caption = + translate("Game has already started, rejoining..."); + g_IsRejoining = true; + return; // we'll process the game setup messages in the next tick + } + g_IsConnecting = false; + + const savegameID = loadSavedGame ? await Engine.PushGuiPage("page_loadgame.xml") : null; + + if (loadSavedGame && !savegameID) + { + Engine.DisconnectNetworkGame(); + cancelSetup(); + return; + } + + Engine.SwitchGuiPage("page_gamesetup.xml", { + "savedGame": savegameID ?? message.savedGame, + "serverName": g_ServerName, + "hasPassword": g_ServerHasPassword + }); + return; // don't process any more messages - leave them for the game GUI loop +} + function switchSetupPage(newPage) { let multiplayerPages = Engine.GetGUIObjectByName("multiplayerPages"); @@ -343,14 +368,14 @@ function switchSetupPage(newPage) Engine.GetGUIObjectByName("continueButton").hidden = newPage == "pageConnecting" || newPage == "pagePassword"; } -function startHost(playername, servername, port, password) +function startHost(playername, servername, port, password, loadSavedGame) { startConnectionStatus("server"); Engine.ConfigDB_CreateValue("user", "playername.multiplayer", playername); Engine.ConfigDB_CreateValue("user", "multiplayerhosting.port", port); Engine.ConfigDB_SaveChanges("user"); - + let hostFeedback = Engine.GetGUIObjectByName("hostFeedback"); // Disallow identically named games in the multiplayer lobby @@ -366,7 +391,8 @@ function startHost(playername, servername, port, password) try { - Engine.StartNetworkHost(playername + (g_UserRating ? " (" + g_UserRating + ")" : ""), port, useSTUN, password, true); + Engine.StartNetworkHost(playername + (g_UserRating ? " (" + g_UserRating + ")" : ""), port, + useSTUN, password, loadSavedGame, true); } catch (e) { diff --git a/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.xml b/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.xml index 9f682a7477..8a4120d79f 100644 --- a/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.xml +++ b/binaries/data/mods/public/gui/gamesetup_mp/gamesetup_mp.xml @@ -10,10 +10,6 @@ - - onTick(); - - Multiplayer @@ -134,7 +130,6 @@ Continue - confirmSetup(); diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js index 658d6f92eb..361e5fe8fb 100644 --- a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js +++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js @@ -3,11 +3,10 @@ */ class HostButton { - constructor(dialog, xmppMessages) + constructor(dialog, xmppMessages, button, loadSavedGame) { - this.hostButton = Engine.GetGUIObjectByName("hostButton"); - this.hostButton.onPress = this.onPress.bind(this); - this.hostButton.caption = translate("Host Game"); + this.hostButton = button; + this.hostButton.onPress = this.onPress.bind(this, loadSavedGame); this.hostButton.hidden = dialog; let onConnectionStatusChange = this.onConnectionStatusChange.bind(this); @@ -21,9 +20,10 @@ class HostButton this.hostButton.enabled = Engine.IsXmppClientConnected(); } - onPress() + onPress(loadSavedGame) { Engine.PushGuiPage("page_gamesetup_mp.xml", { + "loadSavedGame": loadSavedGame, "multiplayerGameType": "host", "name": g_Nickname, "rating": Engine.LobbyGetPlayerRating(g_Nickname) diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameButtons.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/GameButtons.xml new file mode 100644 index 0000000000..b2e9563a1f --- /dev/null +++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameButtons.xml @@ -0,0 +1,14 @@ + + + + + + Host New Game + + + + Host Saved Game + + + + diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js index 75da3b84f5..88518f3f63 100644 --- a/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js +++ b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js @@ -18,8 +18,11 @@ class LobbyPage "buttons": { "buddyButton": buddyButton, "accountSettingsButton": accountSettingsButton, - "hostButton": new HostButton(dialog, xmppMessages), "joinButton": new JoinButton(dialog, gameList), + "hostButton": new HostButton(dialog, xmppMessages, + Engine.GetGUIObjectByName("hostButton"), false), + "hostSavedGameButton": new HostButton(dialog, xmppMessages, + Engine.GetGUIObjectByName("hostSavedGameButton"), true), "leaderboardButton": new LeaderboardButton(xmppMessages, leaderboardPage), "profileButton": new ProfileButton(xmppMessages, profilePage), "quitButton": new QuitButton(dialog, leaderboardPage, profilePage) diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml index d09f1c88a4..4ab433951d 100644 --- a/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml +++ b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml @@ -51,9 +51,9 @@ - - - + + + diff --git a/binaries/data/mods/public/gui/pregame/MainMenuItems.js b/binaries/data/mods/public/gui/pregame/MainMenuItems.js index 21f6c69136..058aa4e371 100644 --- a/binaries/data/mods/public/gui/pregame/MainMenuItems.js +++ b/binaries/data/mods/public/gui/pregame/MainMenuItems.js @@ -187,13 +187,20 @@ var g_MainMenuItems = [ } }, { - "caption": translate("Host Game"), - "tooltip": translate("Host a multiplayer game."), - "onPress": () => { - Engine.PushGuiPage("page_gamesetup_mp.xml", { - "multiplayerGameType": "host" - }); - } + "caption": translate("Host New Game"), + "tooltip": translate("Host a new multiplayer game."), + "onPress": Engine.PushGuiPage.bind(null, "page_gamesetup_mp.xml", { + "multiplayerGameType": "host", + "loadSavedGame": false + }) + }, + { + "caption": translate("Host Saved Game"), + "tooltip": translate("Continue playing a game from a savegame."), + "onPress": Engine.PushGuiPage.bind(null, "page_gamesetup_mp.xml", { + "multiplayerGameType": "host", + "loadSavedGame": true + }) }, { "caption": translate("Game Lobby"), diff --git a/source/network/NetClient.cpp b/source/network/NetClient.cpp index 428a70950a..fb73746929 100644 --- a/source/network/NetClient.cpp +++ b/source/network/NetClient.cpp @@ -84,6 +84,7 @@ CNetClient::CNetClient(CGame* game) : AddTransition(NCS_PREGAME, (uint)NMT_CLIENT_TIMEOUT, NCS_PREGAME, &OnClientTimeout, this); AddTransition(NCS_PREGAME, (uint)NMT_CLIENT_PERFORMANCE, NCS_PREGAME, &OnClientPerformance, this); AddTransition(NCS_PREGAME, (uint)NMT_GAME_START, NCS_LOADING, &OnGameStart, this); + AddTransition(NCS_PREGAME, (uint)NMT_SAVED_GAME_START, NCS_LOADING, &OnSavedGameStart, this); AddTransition(NCS_PREGAME, (uint)NMT_JOIN_SYNC_START, NCS_JOIN_SYNCING, &OnJoinSyncStart, this); AddTransition(NCS_JOIN_SYNCING, (uint)NMT_CHAT, NCS_JOIN_SYNCING, &OnChat, this); @@ -93,6 +94,7 @@ CNetClient::CNetClient(CGame* game) : AddTransition(NCS_JOIN_SYNCING, (uint)NMT_CLIENT_TIMEOUT, NCS_JOIN_SYNCING, &OnClientTimeout, this); AddTransition(NCS_JOIN_SYNCING, (uint)NMT_CLIENT_PERFORMANCE, NCS_JOIN_SYNCING, &OnClientPerformance, this); AddTransition(NCS_JOIN_SYNCING, (uint)NMT_GAME_START, NCS_JOIN_SYNCING, &OnGameStart, this); + AddTransition(NCS_JOIN_SYNCING, (uint)NMT_SAVED_GAME_START, NCS_LOADING, &OnSavedGameStart, this); AddTransition(NCS_JOIN_SYNCING, (uint)NMT_SIMULATION_COMMAND, NCS_JOIN_SYNCING, &OnInGame, this); AddTransition(NCS_JOIN_SYNCING, (uint)NMT_END_COMMAND_BATCH, NCS_JOIN_SYNCING, &OnJoinSyncEndCommandBatch, this); AddTransition(NCS_JOIN_SYNCING, (uint)NMT_LOADED_GAME, NCS_INGAME, &OnLoadedGame, this); @@ -499,6 +501,15 @@ void CNetClient::SendFlareMessage(const CStr& positionX, const CStr& positionY, SendMessage(&flare); } +void CNetClient::SendStartSavedGameMessage(const CStr& initAttribs, const CStr& savedState) +{ + m_SavedState = savedState; + + CGameSavedStartMessage gameSavedStart; + gameSavedStart.m_InitAttributes = initAttribs; + SendMessage(&gameSavedStart); +} + void CNetClient::SendRejoinedMessage() { CRejoinedMessage rejoinedMessage; @@ -534,27 +545,32 @@ bool CNetClient::HandleMessage(CNetMessage* message) { CFileTransferRequestMessage* reqMessage = static_cast(message); - ENSURE(static_cast(reqMessage->m_RequestType) == - CNetFileTransferer::RequestType::REJOIN); + std::string uncompressedGameState{[&] + { + if (static_cast(reqMessage->m_RequestType) == + CNetFileTransferer::RequestType::LOADGAME) + { + return std::exchange(m_SavedState, {}); + } - // TODO: we should support different transfer request types, instead of assuming - // it's always requesting the simulation state + std::stringstream stream; - std::stringstream stream; + LOGMESSAGERENDER("Serializing game at turn %u for rejoining player", m_ClientTurnManager->GetCurrentTurn()); + u32 turn = to_le32(m_ClientTurnManager->GetCurrentTurn()); + stream.write((char*)&turn, sizeof(turn)); - LOGMESSAGERENDER("Serializing game at turn %u for rejoining player", m_ClientTurnManager->GetCurrentTurn()); - u32 turn = to_le32(m_ClientTurnManager->GetCurrentTurn()); - stream.write((char*)&turn, sizeof(turn)); - - bool ok = m_Game->GetSimulation2()->SerializeState(stream); - ENSURE(ok); + bool ok = m_Game->GetSimulation2()->SerializeState(stream); + ENSURE(ok); + return stream.str(); + }()}; // Compress the content with zlib to save bandwidth // (TODO: if this is still too large, compressing with e.g. LZMA works much better) - std::string compressed; - CompressZLib(stream.str(), compressed, true); + std::string compressedGameState; + CompressZLib(std::move(uncompressedGameState), compressedGameState, true); - m_Session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, compressed); + m_Session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, + std::move(compressedGameState)); return true; } @@ -615,6 +631,18 @@ void CNetClient::SendAuthenticateMessage() SendMessage(&authenticate); } +void CNetClient::StartGame(const JS::MutableHandleValue initAttributes, const std::string& savedState) +{ + const auto foundPlayer = m_PlayerAssignments.find(m_GUID); + const i32 player{foundPlayer != m_PlayerAssignments.end() ? foundPlayer->second.m_PlayerID : -1}; + + m_ClientTurnManager = new CNetClientTurnManager{*m_Game->GetSimulation2(), *this, + static_cast(m_HostID), m_Game->GetReplayLogger()}; + + m_Game->SetPlayerID(player); + m_Game->StartGame(initAttributes, savedState); +} + bool CNetClient::OnConnect(CNetClient* client, CFsmEvent* event) { ENSURE(event->GetType() == (uint)NMT_CONNECT_COMPLETE); @@ -690,7 +718,8 @@ bool CNetClient::OnAuthenticate(CNetClient* client, CFsmEvent* event) client->PushGuiMessage( "type", "netstatus", "status", "authenticated", - "rejoining", client->m_Rejoin); + "rejoining", client->m_Rejoin, + "savedGame", message->m_Code == ARC_OK_SAVED_GAME); return true; } @@ -769,29 +798,40 @@ bool CNetClient::OnGameStart(CNetClient* client, CFsmEvent* event) CGameStartMessage* message = static_cast(event->GetParamRef()); - // Find the player assigned to our GUID - int player = -1; - 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( - *client->m_Game->GetSimulation2(), *client, client->m_HostID, client->m_Game->GetReplayLogger()); - - // Parse init attributes. - const ScriptInterface& scriptInterface = client->m_Game->GetSimulation2()->GetScriptInterface(); - ScriptRequest rq(scriptInterface); - JS::RootedValue initAttribs(rq.cx); + const ScriptInterface& scriptInterface{client->m_Game->GetSimulation2()->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, ""); - - client->PushGuiMessage("type", "start", - "initAttributes", initAttribs); - + client->PushGuiMessage("type", "start", "initAttributes", initAttribs); + client->StartGame(&initAttribs, ""); return true; } +bool CNetClient::OnSavedGameStart(CNetClient* client, CFsmEvent* event) +{ + ENSURE(event->GetType() == static_cast(NMT_SAVED_GAME_START)); + CGameSavedStartMessage* message{static_cast(event->GetParamRef())}; + + const ScriptInterface& scriptInterface{client->m_Game->GetSimulation2()->GetScriptInterface()}; + ScriptRequest rq{scriptInterface}; + const std::shared_ptr initAttribs{std::make_shared(rq.cx)}; + Script::ParseJSON(rq, message->m_InitAttributes, &*initAttribs); + + client->PushGuiMessage("type", "start", "initAttributes", *initAttribs); + + client->m_Session->GetFileTransferer().StartTask(CNetFileTransferer::RequestType::LOADGAME, + [client, initAttribs](std::string buffer) + { + std::string state; + DecompressZLib(buffer, state, true); + + client->StartGame(&*initAttribs, state); + }); + return true; +} + + bool CNetClient::OnJoinSyncStart(CNetClient* client, CFsmEvent* event) { ENSURE(event->GetType() == (uint)NMT_JOIN_SYNC_START); diff --git a/source/network/NetClient.h b/source/network/NetClient.h index f17646cec4..64c59a5e39 100644 --- a/source/network/NetClient.h +++ b/source/network/NetClient.h @@ -237,6 +237,8 @@ public: void SendStartGameMessage(const CStr& initAttribs); + void SendStartSavedGameMessage(const CStr& initAttribs, const CStr& savedState); + /** * Call when the client (player or observer) has sent a flare. */ @@ -283,6 +285,7 @@ private: static bool OnPlayerAssignment(CNetClient* client, CFsmEvent* event); static bool OnInGame(CNetClient* client, CFsmEvent* event); static bool OnGameStart(CNetClient* client, CFsmEvent* event); + static bool OnSavedGameStart(CNetClient* client, CFsmEvent* event); static bool OnJoinSyncStart(CNetClient* client, CFsmEvent* event); static bool OnJoinSyncEndCommandBatch(CNetClient* client, CFsmEvent* event); static bool OnFlare(CNetClient* client, CFsmEvent* event); @@ -299,6 +302,12 @@ private: */ void SetAndOwnSession(CNetClientSession* session); + /** + * Starts a game with the specified init attributes and saved state. Called + * by the start game and start saved game callbacks. + */ + void StartGame(const JS::MutableHandleValue initAttributes, const std::string& savedState); + /** * Push a message onto the GUI queue listing the current player assignments. */ @@ -349,6 +358,8 @@ private: /// Serialized game state received when joining an in-progress game std::string m_JoinSyncBuffer; + std::string m_SavedState; + /// Time when the server was last checked for timeouts and bad latency std::time_t m_LastConnectionCheck; }; diff --git a/source/network/NetMessage.cpp b/source/network/NetMessage.cpp index 2dabfacf8e..4cd0defa1d 100644 --- a/source/network/NetMessage.cpp +++ b/source/network/NetMessage.cpp @@ -183,6 +183,10 @@ CNetMessage* CNetMessageFactory::CreateMessage(const void* pData, pNewMessage = new CGameStartMessage; break; + case NMT_SAVED_GAME_START: + pNewMessage = new CGameSavedStartMessage; + break; + case NMT_END_COMMAND_BATCH: pNewMessage = new CEndCommandBatchMessage; break; diff --git a/source/network/NetMessages.h b/source/network/NetMessages.h index cb41fe96ae..05b26bdb16 100644 --- a/source/network/NetMessages.h +++ b/source/network/NetMessages.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -75,6 +75,7 @@ enum NetMessageType NMT_LOADED_GAME, NMT_GAME_START, + NMT_SAVED_GAME_START, NMT_END_COMMAND_BATCH, NMT_SYNC_CHECK, // OOS-detection hash checking @@ -88,6 +89,7 @@ enum NetMessageType enum AuthenticateResultCode { ARC_OK, + ARC_OK_SAVED_GAME, ARC_OK_REJOINING, ARC_PASSWORD_INVALID, }; @@ -226,6 +228,10 @@ START_NMT_CLASS_(GameStart, NMT_GAME_START) NMT_FIELD(CStr, m_InitAttributes) END_NMT_CLASS() +START_NMT_CLASS_(GameSavedStart, NMT_SAVED_GAME_START) + NMT_FIELD(CStr, m_InitAttributes) +END_NMT_CLASS() + START_NMT_CLASS_(EndCommandBatch, NMT_END_COMMAND_BATCH) NMT_FIELD_INT(m_Turn, u32, 4) NMT_FIELD_INT(m_TurnLength, u32, 2) diff --git a/source/network/NetServer.cpp b/source/network/NetServer.cpp index 4da6b8f022..5ae464e263 100644 --- a/source/network/NetServer.cpp +++ b/source/network/NetServer.cpp @@ -95,7 +95,8 @@ static CStr DebugName(CNetServerSession* session) * See https://gitea.wildfiregames.com/0ad/0ad/issues/654 */ -CNetServerWorker::CNetServerWorker(bool useLobbyAuth) : +CNetServerWorker::CNetServerWorker(const bool continueSavedGame, const bool useLobbyAuth) : + m_ContinuesSavedGame{continueSavedGame}, m_LobbyAuth(useLobbyAuth), m_Shutdown(false), m_ScriptInterface(NULL), @@ -623,14 +624,15 @@ void CNetServerWorker::HandleMessageReceive(const CNetMessage* message, CNetServ if (message->GetType() == NMT_FILE_TRANSFER_REQUEST) { CFileTransferRequestMessage* reqMessage = (CFileTransferRequestMessage*)message; - ENSURE(static_cast(reqMessage->m_RequestType) == - CNetFileTransferer::RequestType::REJOIN); - // Rejoining client got our JoinSyncStart after we received the state from - // another client, and has now requested that we forward it to them + // A client requested the gamestate. Clients only request the gamestate when we sent them a + // JoinSyncStart or a GameSavedStart message. We only send those messages after we received the + // gamestate. + // For joins and loads the gamestate is in a different format. Send the respective one. - ENSURE(!m_JoinSyncFile.empty()); - session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, m_JoinSyncFile); + session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, + static_cast(reqMessage->m_RequestType) == + CNetFileTransferer::RequestType::LOADGAME ? m_SavedState : m_JoinSyncFile); return; } @@ -663,6 +665,7 @@ void CNetServerWorker::SetupSession(CNetServerSession* session) session->AddTransition(NSS_PREGAME, (uint)NMT_ASSIGN_PLAYER, NSS_PREGAME, &OnAssignPlayer, session); session->AddTransition(NSS_PREGAME, (uint)NMT_KICKED, NSS_PREGAME, &OnKickPlayer, session); session->AddTransition(NSS_PREGAME, (uint)NMT_GAME_START, NSS_PREGAME, &OnGameStart, session); + session->AddTransition(NSS_PREGAME, (uint)NMT_SAVED_GAME_START, NSS_PREGAME, &OnSavedGameStart, session); session->AddTransition(NSS_PREGAME, (uint)NMT_LOADED_GAME, NSS_INGAME, &OnLoadedGame, session); session->AddTransition(NSS_JOIN_SYNCING, (uint)NMT_KICKED, NSS_JOIN_SYNCING, &OnKickPlayer, session); @@ -1094,7 +1097,8 @@ bool CNetServerWorker::OnAuthenticate(CNetServerSession* session, CFsmEvent* eve session->SetHostID(newHostID); CAuthenticateResultMessage authenticateResult; - authenticateResult.m_Code = isRejoining ? ARC_OK_REJOINING : ARC_OK; + authenticateResult.m_Code = isRejoining ? ARC_OK_REJOINING : + server.m_ContinuesSavedGame ? ARC_OK_SAVED_GAME : ARC_OK; authenticateResult.m_HostID = newHostID; authenticateResult.m_Message = L"Logged in"; authenticateResult.m_IsController = 0; @@ -1331,6 +1335,25 @@ bool CNetServerWorker::OnGameStart(CNetServerSession* session, CFsmEvent* event) return true; } +bool CNetServerWorker::OnSavedGameStart(CNetServerSession* session, CFsmEvent* event) +{ + ENSURE(event->GetType() == static_cast(NMT_SAVED_GAME_START)); + CNetServerWorker& server{session->GetServer()}; + + if (session->GetGUID() != server.m_ControllerGUID) + return true; + + CGameSavedStartMessage* message = static_cast(event->GetParamRef()); + session->GetFileTransferer().StartTask(CNetFileTransferer::RequestType::LOADGAME, + [&server, initAttributes = std::move(message->m_InitAttributes)](std::string buffer) + { + server.m_SavedState = std::move(buffer); + + server.StartSavedGame(initAttributes); + }); + return true; +} + bool CNetServerWorker::OnLoadedGame(CNetServerSession* loadedSession, CFsmEvent* event) { ENSURE(event->GetType() == (uint)NMT_LOADED_GAME); @@ -1354,6 +1377,10 @@ bool CNetServerWorker::OnLoadedGame(CNetServerSession* loadedSession, CFsmEvent* message.m_Clients.push_back(client); } + // If no other player is loading the server can clear the savestate + if (message.m_Clients.empty()) + server.m_SavedState.clear(); + // Send to the client who has loaded the game but did not reach the NSS_INGAME state yet loadedSession->SendMessage(&message); server.Broadcast(&message, { NSS_INGAME }); @@ -1515,7 +1542,7 @@ bool CNetServerWorker::CheckGameLoadStatus(CNetServerSession* changedSession) return true; } -void CNetServerWorker::StartGame(const CStr& initAttribs) +void CNetServerWorker::PreStartGame(const CStr& initAttribs) { for (std::pair& player : m_PlayerAssignments) if (player.second.m_Enabled && player.second.m_PlayerID != -1 && player.second.m_Status == 0) @@ -1546,12 +1573,26 @@ void CNetServerWorker::StartGame(const CStr& initAttribs) // Update init attributes. They should no longer change. Script::ParseJSON(ScriptRequest(m_ScriptInterface), initAttribs, &m_InitAttributes); +} + +void CNetServerWorker::StartGame(const CStr& initAttribs) +{ + PreStartGame(initAttribs); CGameStartMessage gameStart; gameStart.m_InitAttributes = initAttribs; Broadcast(&gameStart, { NSS_PREGAME }); } +void CNetServerWorker::StartSavedGame(const CStr& initAttribs) +{ + PreStartGame(initAttribs); + + CGameSavedStartMessage gameSavedStart; + gameSavedStart.m_InitAttributes = initAttribs; + Broadcast(&gameSavedStart, { NSS_PREGAME }); +} + CStrW CNetServerWorker::SanitisePlayerName(const CStrW& original) { const size_t MAX_LENGTH = 32; @@ -1608,8 +1649,8 @@ void CNetServerWorker::SendHolePunchingMessage(const CStr& ipStr, u16 port) -CNetServer::CNetServer(bool useLobbyAuth) : - m_Worker(new CNetServerWorker(useLobbyAuth)), +CNetServer::CNetServer(const bool continueSavedGame, const bool useLobbyAuth) : + m_Worker{new CNetServerWorker{continueSavedGame, useLobbyAuth}}, m_LobbyAuth(useLobbyAuth), m_UseSTUN(false), m_PublicIp(""), m_PublicPort(20595), m_Password() { } diff --git a/source/network/NetServer.h b/source/network/NetServer.h index 0e8f616021..496373f675 100644 --- a/source/network/NetServer.h +++ b/source/network/NetServer.h @@ -112,7 +112,7 @@ public: * Construct a new network server. * once this many players are connected (intended for the command-line testing mode). */ - CNetServer(bool useLobbyAuth = false); + CNetServer(const bool isSavedGame, const bool useLobbyAuth = false); ~CNetServer(); @@ -234,7 +234,7 @@ public: private: friend class CNetServer; - CNetServerWorker(bool useLobbyAuth); + CNetServerWorker(const bool continuesSavedGame, const bool useLobbyAuth); ~CNetServerWorker(); bool CheckPassword(const std::string& password, const std::string& salt) const; @@ -255,11 +255,22 @@ private: */ void AssignPlayer(int playerID, const CStr& guid); + /** + * Switch in game mode. The clients will have to be notified to start the + * game. This method is called by StartGame and StartSavedGame + */ + void PreStartGame(const CStr& initAttribs); + /** * Switch in game mode and notify all clients to start the game. */ void StartGame(const CStr& initAttribs); + /** + * Switch in game mode and notify all clients to start the saved game. + */ + void StartSavedGame(const CStr& initAttribs); + /** * Make a player name 'nicer' by limiting the length and removing forbidden characters etc. */ @@ -306,6 +317,7 @@ private: static bool OnGameSetup(CNetServerSession* session, CFsmEvent* event); static bool OnAssignPlayer(CNetServerSession* session, CFsmEvent* event); static bool OnGameStart(CNetServerSession* session, CFsmEvent* event); + static bool OnSavedGameStart(CNetServerSession* session, CFsmEvent* event); static bool OnLoadedGame(CNetServerSession* session, CFsmEvent* event); static bool OnJoinSyncingLoadedGame(CNetServerSession* session, CFsmEvent* event); static bool OnRejoined(CNetServerSession* session, CFsmEvent* event); @@ -349,6 +361,11 @@ private: */ JS::PersistentRootedValue m_InitAttributes; + /** + * Whether this match continues a saved game. + */ + const bool m_ContinuesSavedGame; + /** * Whether this match requires lobby authentication. */ @@ -400,6 +417,11 @@ private: */ std::string m_JoinSyncFile; + /** + * The loaded game data when a game is loaded. + */ + std::string m_SavedState; + /** * Time when the clients connections were last checked for timeouts and latency. */ diff --git a/source/network/scripting/JSInterface_Network.cpp b/source/network/scripting/JSInterface_Network.cpp index ffc897d87a..de4f98a450 100644 --- a/source/network/scripting/JSInterface_Network.cpp +++ b/source/network/scripting/JSInterface_Network.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -33,6 +33,7 @@ #include "ps/GUID.h" #include "ps/Hashing.h" #include "ps/Pyrogenesis.h" +#include "ps/SavedGame.h" #include "ps/Util.h" #include "scriptinterface/FunctionWrapper.h" #include "scriptinterface/StructuredClone.h" @@ -62,7 +63,8 @@ bool HasNetClient() return !!g_NetClient; } -void StartNetworkHost(const ScriptRequest& rq, const CStrW& playerName, const u16 serverPort, bool useSTUN, const CStr& password, bool storeReplay) +void StartNetworkHost(const ScriptRequest& rq, const CStrW& playerName, const u16 serverPort, bool useSTUN, + const CStr& password, const bool continueSavedGame, bool storeReplay) { ENSURE(!g_NetClient); ENSURE(!g_NetServer); @@ -70,7 +72,7 @@ void StartNetworkHost(const ScriptRequest& rq, const CStrW& playerName, const u1 // Always use lobby authentication for lobby matches to prevent impersonation and smurfing, in particular through mods that implemented an UI for arbitrary or other players nicknames. bool hasLobby = !!g_XmppClient; - g_NetServer = new CNetServer(hasLobby); + g_NetServer = new CNetServer(continueSavedGame, hasLobby); if (!g_NetServer->SetupConnection(serverPort)) { @@ -256,14 +258,29 @@ void ClearAllPlayerReady () g_NetClient->SendClearAllReadyMessage(); } -void StartNetworkGame(const ScriptInterface& scriptInterface, JS::HandleValue attribs1) +void StartNetworkGame(const ScriptInterface& scriptInterface, JS::HandleValue savegame, JS::HandleValue attribs1) { ENSURE(g_NetClient); - // TODO: This is a workaround because we need to pass a MutableHandle to a JSAPI functions somewhere (with no obvious reason). ScriptRequest rq(scriptInterface); + JS::RootedValue attribs(rq.cx, attribs1); - g_NetClient->SendStartGameMessage(Script::StringifyJSON(rq, &attribs)); + std::string attributesAsString{Script::StringifyJSON(rq, &attribs)}; + + if (savegame.isFalse()) + { + g_NetClient->SendStartGameMessage(attributesAsString); + return; + } + + std::wstring savegameID; + Script::FromJSVal(rq, savegame, savegameID); + + const std::optional loadResult{SavedGames::Load(scriptInterface, savegameID)}; + if (loadResult) + g_NetClient->SendStartSavedGameMessage(attributesAsString, loadResult->savedState); + else + ScriptException::Raise(rq, "Failed to load the saved game: \"%ls\"", savegameID.c_str()); } void SetTurnLength(int length) diff --git a/source/ps/SavedGame.cpp b/source/ps/SavedGame.cpp index 5e3135200e..2a1cda1d1c 100644 --- a/source/ps/SavedGame.cpp +++ b/source/ps/SavedGame.cpp @@ -204,7 +204,8 @@ private: std::string* m_SavedState; }; -Status SavedGames::Load(const std::wstring& name, const ScriptInterface& scriptInterface, JS::MutableHandleValue metadata, std::string& savedState) +std::optional SavedGames::Load(const ScriptInterface& scriptInterface, + const std::wstring& name) { // Determine the filename to load const VfsPath basename(L"saves/" + name); @@ -212,20 +213,41 @@ Status SavedGames::Load(const std::wstring& name, const ScriptInterface& scriptI // Don't crash just because file isn't found, this can happen if the file is deleted from the OS if (!VfsFileExists(filename)) - return ERR::FILE_NOT_FOUND; + return std::nullopt; OsPath realPath; - WARN_RETURN_STATUS_IF_ERR(g_VFS->GetRealPath(filename, realPath)); + { + const Status status{g_VFS->GetRealPath(filename, realPath)}; + if (status < 0) + { + DEBUG_WARN_ERR(status); + return std::nullopt; + } + } PIArchiveReader archiveReader = CreateArchiveReader_Zip(realPath); if (!archiveReader) - WARN_RETURN(ERR::FAIL); + { + DEBUG_WARN_ERR(ERR::FAIL); + return std::nullopt; + } + std::string savedState; CGameLoader loader(scriptInterface, &savedState); - WARN_RETURN_STATUS_IF_ERR(archiveReader->ReadEntries(CGameLoader::ReadEntryCallback, (uintptr_t)&loader)); - metadata.set(loader.GetMetadata()); + { + const Status status{archiveReader->ReadEntries(CGameLoader::ReadEntryCallback, + reinterpret_cast(&loader))}; + if (status < 0) + { + DEBUG_WARN_ERR(status); + return std::nullopt; + } + } + const ScriptRequest rq{scriptInterface}; + JS::RootedValue metadata{rq.cx, loader.GetMetadata()}; - return INFO::OK; + // `std::make_optional` can't be used since `LoadResult` doesn't have a constructor. + return {{metadata, std::move(savedState)}}; } JS::Value SavedGames::GetSavedGames(const ScriptInterface& scriptInterface) diff --git a/source/ps/SavedGame.h b/source/ps/SavedGame.h index 63360ccc37..8ce0f2783c 100644 --- a/source/ps/SavedGame.h +++ b/source/ps/SavedGame.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -19,8 +19,11 @@ #define INCLUDED_SAVEDGAME #include "ps/CStr.h" +#include "scriptinterface/ScriptTypes.h" #include "scriptinterface/StructuredClone.h" +#include + class CSimulation2; /** @@ -59,18 +62,24 @@ namespace SavedGames */ Status SavePrefix(const CStrW& prefix, const CStrW& description, CSimulation2& simulation, const Script::StructuredClone& guiMetadataClone); + struct LoadResult + { + // Object containing metadata associated with saved game, + // parsed from metadata.json inside the archive. + JS::Value metadata; + // Serialized simulation state stored as string of bytes, + // loaded from simulation.dat inside the archive. + std::string savedState; + }; + /** * Load saved game archive with the given name * * @param name filename of saved game (without path or extension) * @param scriptInterface - * @param[out] metadata object containing metadata associated with saved game, - * parsed from metadata.json inside the archive. - * @param[out] savedState serialized simulation state stored as string of bytes, - * loaded from simulation.dat inside the archive. - * @return INFO::OK if successfully loaded, else an error Status + * @return An empty `std::optional` if an error ocoured. */ - Status Load(const std::wstring& name, const ScriptInterface& scriptInterface, JS::MutableHandleValue metadata, std::string& savedState); + std::optional Load(const ScriptInterface& scriptInterface, const std::wstring& name); /** * Get list of saved games for GUI script usage diff --git a/source/ps/scripting/JSInterface_SavedGame.cpp b/source/ps/scripting/JSInterface_SavedGame.cpp index e02a1998f7..4626d9a194 100644 --- a/source/ps/scripting/JSInterface_SavedGame.cpp +++ b/source/ps/scripting/JSInterface_SavedGame.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -29,6 +29,8 @@ #include "simulation2/Simulation2.h" #include "simulation2/system/TurnManager.h" +#include + namespace JSI_SavedGame { JS::Value GetSavedGames(const ScriptInterface& scriptInterface) @@ -75,6 +77,13 @@ void QuickLoad() LOGERROR("Can't load quicksave if game is not running!"); } +JS::Value LoadSavedGameMetadata(const ScriptInterface& scriptInterface, const std::wstring& name) +{ + std::optional data{SavedGames::Load(scriptInterface, name)}; + + return data ? data->metadata : JS::UndefinedValue(); +} + JS::Value StartSavedGame(const ScriptInterface& scriptInterface, const std::wstring& name) { // We need to be careful with different compartments and contexts. @@ -88,13 +97,12 @@ JS::Value StartSavedGame(const ScriptInterface& scriptInterface, const std::wstr ENSURE(!g_Game); - // Load the saved game data from disk - JS::RootedValue guiContextMetadata(rqGui.cx); - std::string savedState; - Status err = SavedGames::Load(name, scriptInterface, &guiContextMetadata, savedState); - if (err < 0) + std::optional data{SavedGames::Load(scriptInterface, name)}; + if (!data) return JS::UndefinedValue(); + JS::RootedValue guiContextMetadata{rqGui.cx, data->metadata}; + g_Game = new CGame(true); { @@ -109,7 +117,7 @@ JS::Value StartSavedGame(const ScriptInterface& scriptInterface, const std::wstr Script::GetProperty(rqGame, gameContextMetadata, "playerID", playerID); g_Game->SetPlayerID(playerID); - g_Game->StartGame(&gameInitAttributes, savedState); + g_Game->StartGame(&gameInitAttributes, data->savedState); } return guiContextMetadata; @@ -131,6 +139,7 @@ void RegisterScriptFunctions(const ScriptRequest& rq) ScriptFunction::Register<&QuickSave>(rq, "QuickSave"); ScriptFunction::Register<&QuickLoad>(rq, "QuickLoad"); ScriptFunction::Register<&ActivateRejoinTest>(rq, "ActivateRejoinTest"); + ScriptFunction::Register<&LoadSavedGameMetadata>(rq, "LoadSavedGameMetadata"); ScriptFunction::Register<&StartSavedGame>(rq, "StartSavedGame"); } }