Multiplayer saved games

Enables to save multiplayer games.
When the savegame is loaded, the settings are frozen (except the non-AI-player assignment settings).
This commit is contained in:
phosit 2024-08-31 10:54:56 +02:00 committed by phosit
parent 20b4937ffd
commit b90280855f
42 changed files with 542 additions and 199 deletions

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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" },

View file

@ -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.

View file

@ -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())

View file

@ -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;
}

View file

@ -1,8 +1,8 @@
class AIGameSettingControlDropdown extends GameSettingControlDropdown
{
onOpenPage(playerIndex)
onOpenPage(playerIndex, enabled)
{
this.setEnabled(true);
this.setEnabled(enabled);
this.playerIndex = playerIndex;
this.render();
}

View file

@ -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:");

View file

@ -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)

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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)
{

View file

@ -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);
}
};

View file

@ -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

View file

@ -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();
}

View file

@ -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)
};

View file

@ -67,23 +67,27 @@
<object name="bottomLeftPanel">
<object size="20 100%-32 100%-430 100%">
<object size="20 100%-32 100%-698 100%">
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Tooltip.xml"/>
</object>
<object size="0 100%-28 100%-320 100%">
<object size="0 100%-28 100%-470 100%">
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/GameSettingWarning.xml"/>
</object>
</object>
<object name="bottomRightPanel" size="100%-314 100%-28 100% 100%">
<object name="bottomRightPanel" size="100%-464 100%-28 100% 100%">
<object size="0 0 140 100%">
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavedGameLabel.xml"/>
</object>
<object size="150 0 290 100%">
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/CancelButton.xml"/>
</object>
<object size="150 0 290 100%">
<object size="300 0 440 100%">
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ReadyButton.xml"/>
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/StartGameButton.xml"/>
</object>

View file

@ -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()

View file

@ -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()

View file

@ -0,0 +1,8 @@
class SavedGameLabel
{
constructor(isSavedGame)
{
if (isSavedGame)
Engine.GetGUIObjectByName("savedGameLabel").hidden = false;
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<object
name="savedGameLabel"
type="text"
style="ModernLabelText"
tooltip_style="onscreenToolTip"
hidden="true">
<translatableAttribute id="caption">Saved game</translatableAttribute>
<translatableAttribute id="tooltip">
The controller loaded a saved game.
</translatableAttribute>
</object>

View file

@ -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()

View file

@ -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,

View file

@ -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);

View file

@ -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)
{

View file

@ -10,10 +10,6 @@
<object name="multiplayerPages" type="image" style="ModernDialog" size="50%-230 50%-120 50%+230 50%+120">
<action on="Tick">
onTick();
</action>
<object style="ModernLabelText" type="text" size="50%-128 0%-16 50%+128 16">
<translatableAttribute id="caption">Multiplayer</translatableAttribute>
</object>
@ -134,7 +130,6 @@
<object name="continueButton" hotkey="confirm" type="button" size="50%+5 100%-45 100%-18 100%-17" style="ModernButtonRed">
<translatableAttribute id="caption">Continue</translatableAttribute>
<action on="Press">confirmSetup();</action>
</object>
<object type="button" style="ModernButtonRed" size="18 100%-45 50%-5 100%-17" hotkey="cancel">

View file

@ -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)

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<object>
<object name="joinButton" size="0 100%-104 100% 100%-81" type="button" style="ModernButtonRed"/>
<object name="hostButton" size="0 100%-79 100% 100%-54" type="button" style="ModernButtonRed">
<translatableAttribute id="caption">Host New Game</translatableAttribute>
</object>
<object name="hostSavedGameButton" size="0 100%-52 100% 100%-27" type="button" style="ModernButtonRed">
<translatableAttribute id="caption">Host Saved Game</translatableAttribute>
</object>
<object name="leaveButton" size="0 100%-25 100% 100%" type="button" style="ModernButtonRed"/>
</object>

View file

@ -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)

View file

@ -51,9 +51,9 @@
<object name="subjectPanel">
<include file="gui/lobby/LobbyPage/Subject.xml"/>
</object>
<object name="joinButton" size="0 100%-79 100% 100%-54" type="button" style="ModernButtonRed"/>
<object name="hostButton" size="0 100%-52 100% 100%-27" type="button" style="ModernButtonRed"/>
<object name="leaveButton" size="0 100%-25 100% 100%" type="button" style="ModernButtonRed"/>
<object name="gameButtonsPanel">
<include file="gui/lobby/LobbyPage/GameButtons.xml"/>
</object>
</object>
</object>

View file

@ -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"),

View file

@ -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<CFileTransferRequestMessage*>(message);
ENSURE(static_cast<CNetFileTransferer::RequestType>(reqMessage->m_RequestType) ==
CNetFileTransferer::RequestType::REJOIN);
std::string uncompressedGameState{[&]
{
if (static_cast<CNetFileTransferer::RequestType>(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<int>(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<CGameStartMessage*>(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<uint>(NMT_SAVED_GAME_START));
CGameSavedStartMessage* message{static_cast<CGameSavedStartMessage*>(event->GetParamRef())};
const ScriptInterface& scriptInterface{client->m_Game->GetSimulation2()->GetScriptInterface()};
ScriptRequest rq{scriptInterface};
const std::shared_ptr<JS::RootedValue> initAttribs{std::make_shared<JS::RootedValue>(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);

View file

@ -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;
};

View file

@ -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;

View file

@ -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)

View file

@ -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<CNetFileTransferer::RequestType>(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<CNetFileTransferer::RequestType>(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<uint>(NMT_SAVED_GAME_START));
CNetServerWorker& server{session->GetServer()};
if (session->GetGUID() != server.m_ControllerGUID)
return true;
CGameSavedStartMessage* message = static_cast<CGameSavedStartMessage*>(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<const CStr, PlayerAssignment>& 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()
{
}

View file

@ -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.
*/

View file

@ -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<SavedGames::LoadResult> 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)

View file

@ -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::LoadResult> 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<uintptr_t>(&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)

View file

@ -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 <optional>
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<LoadResult> Load(const ScriptInterface& scriptInterface, const std::wstring& name);
/**
* Get list of saved games for GUI script usage

View file

@ -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 <optional>
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<SavedGames::LoadResult> 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<SavedGames::LoadResult> 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");
}
}