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:
parent
3cfcaf7be6
commit
86d360a887
@ -0,0 +1,16 @@
|
|||||||
|
GameSettings.prototype.Attributes.Savegame = class extends GameSetting
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
|
||||||
|
toInitAttributes(attribs)
|
||||||
|
{
|
||||||
|
if (this.value !== null)
|
||||||
|
attribs.settings.Savegame = this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fromInitAttributes(attribs)
|
||||||
|
{
|
||||||
|
const newValue = this.getLegacySetting(attribs, "Savegame");
|
||||||
|
this.value = newValue ?? null;
|
||||||
|
}
|
||||||
|
};
|
@ -189,6 +189,7 @@
|
|||||||
{ "nick": "MattDoerksen", "name": "Matt Doerksen" },
|
{ "nick": "MattDoerksen", "name": "Matt Doerksen" },
|
||||||
{ "nick": "mattlott", "name": "Matt Lott" },
|
{ "nick": "mattlott", "name": "Matt Lott" },
|
||||||
{ "nick": "maveric", "name": "Anton Protko" },
|
{ "nick": "maveric", "name": "Anton Protko" },
|
||||||
|
{ "nick": "mbusy", "name": "Maxime Busy" },
|
||||||
{ "nick": "Micnasty", "name": "Travis Gorkin" },
|
{ "nick": "Micnasty", "name": "Travis Gorkin" },
|
||||||
{ "name": "Mikołaj \"Bajter\" Korcz" },
|
{ "name": "Mikołaj \"Bajter\" Korcz" },
|
||||||
{ "nick": "mimo" },
|
{ "nick": "mimo" },
|
||||||
|
@ -95,8 +95,11 @@ class PlayerAssignmentsController
|
|||||||
*/
|
*/
|
||||||
onClientJoin(newGUID, newAssignments)
|
onClientJoin(newGUID, newAssignments)
|
||||||
{
|
{
|
||||||
if (!g_IsController || newAssignments[newGUID].player != -1)
|
if (!g_IsController || newAssignments[newGUID].player !== -1 ||
|
||||||
|
g_GameSettings.savegame.value !== null)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Assign the client (or only buddies if prefered) to a free slot
|
// Assign the client (or only buddies if prefered) to a free slot
|
||||||
if (newGUID != Engine.GetPlayerGUID())
|
if (newGUID != Engine.GetPlayerGUID())
|
||||||
|
@ -38,12 +38,12 @@ SetupWindowPages.AIConfigPage = class
|
|||||||
return this.row++;
|
return this.row++;
|
||||||
}
|
}
|
||||||
|
|
||||||
openPage(playerIndex)
|
openPage(playerIndex, enabled)
|
||||||
{
|
{
|
||||||
this.playerIndex = playerIndex;
|
this.playerIndex = playerIndex;
|
||||||
|
|
||||||
for (let handler of this.openPageHandlers)
|
for (let handler of this.openPageHandlers)
|
||||||
handler(playerIndex);
|
handler(playerIndex, enabled);
|
||||||
|
|
||||||
this.aiConfigPage.hidden = false;
|
this.aiConfigPage.hidden = false;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
class AIGameSettingControlDropdown extends GameSettingControlDropdown
|
class AIGameSettingControlDropdown extends GameSettingControlDropdown
|
||||||
{
|
{
|
||||||
onOpenPage(playerIndex)
|
onOpenPage(playerIndex, enabled)
|
||||||
{
|
{
|
||||||
this.setEnabled(true);
|
this.setEnabled(enabled);
|
||||||
this.playerIndex = playerIndex;
|
this.playerIndex = playerIndex;
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
@ -55,6 +55,11 @@ class GameSettingControl /* extends Profilable /* Uncomment to profile controls
|
|||||||
|
|
||||||
if (this.onPlayerAssignmentsChange)
|
if (this.onPlayerAssignmentsChange)
|
||||||
this.playerAssignmentsController.registerPlayerAssignmentsChangeHandler(this.onPlayerAssignmentsChange.bind(this));
|
this.playerAssignmentsController.registerPlayerAssignmentsChangeHandler(this.onPlayerAssignmentsChange.bind(this));
|
||||||
|
|
||||||
|
g_GameSettings.savegame.watch(() => {
|
||||||
|
const isSavegame = g_GameSettings.savegame.value !== null;
|
||||||
|
this.setEnabled(this.onSavegameChanged?.(isSavegame) ?? !isSavegame);
|
||||||
|
}, ["value"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(titleCaption)
|
setTitle(titleCaption)
|
||||||
|
@ -5,6 +5,9 @@ PlayerSettingControls.AIConfigButton = class AIConfigButton extends GameSettingC
|
|||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
this.aiConfigButton = Engine.GetGUIObjectByName("aiConfigButton[" + this.playerIndex + "]");
|
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"]);
|
g_GameSettings.playerAI.watch(() => this.render(), ["values"]);
|
||||||
// Save little performance by not reallocating every call
|
// Save little performance by not reallocating every call
|
||||||
@ -12,12 +15,6 @@ PlayerSettingControls.AIConfigButton = class AIConfigButton extends GameSettingC
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad()
|
|
||||||
{
|
|
||||||
let aiConfigPage = this.setupWindow.pages.AIConfigPage;
|
|
||||||
this.aiConfigButton.onPress = aiConfigPage.openPage.bind(aiConfigPage, this.playerIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
render()
|
render()
|
||||||
{
|
{
|
||||||
this.aiConfigButton.hidden = !g_GameSettings.playerAI.get(this.playerIndex);
|
this.aiConfigButton.hidden = !g_GameSettings.playerAI.get(this.playerIndex);
|
||||||
|
@ -34,6 +34,19 @@ PlayerSettingControls.PlayerAssignment = class PlayerAssignment extends GameSett
|
|||||||
g_GameSettings.playerCount.watch((_, oldNb) => this.OnPlayerNbChange(oldNb), ["nbPlayers"]);
|
g_GameSettings.playerCount.watch((_, oldNb) => this.OnPlayerNbChange(oldNb), ["nbPlayers"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSavegameChanged(isSavegame)
|
||||||
|
{
|
||||||
|
const savedAI = isSavegame && g_GameSettings.playerAI.get(this.playerIndex);
|
||||||
|
|
||||||
|
if (savedAI)
|
||||||
|
this.setSelectedValue(savedAI.bot);
|
||||||
|
else
|
||||||
|
this.rebuildList();
|
||||||
|
|
||||||
|
// If loading a savegame, AIs have to stay.
|
||||||
|
return !savedAI;
|
||||||
|
}
|
||||||
|
|
||||||
setControl()
|
setControl()
|
||||||
{
|
{
|
||||||
this.dropdown = Engine.GetGUIObjectByName("playerAssignment[" + this.playerIndex + "]");
|
this.dropdown = Engine.GetGUIObjectByName("playerAssignment[" + this.playerIndex + "]");
|
||||||
@ -103,9 +116,14 @@ PlayerSettingControls.PlayerAssignment = class PlayerAssignment extends GameSett
|
|||||||
// TODO: this particular bit is done for each row, which is unnecessarily inefficient.
|
// TODO: this particular bit is done for each row, which is unnecessarily inefficient.
|
||||||
this.playerItems = sortGUIDsByPlayerID().map(
|
this.playerItems = sortGUIDsByPlayerID().map(
|
||||||
this.clientItemFactory.createItem.bind(this.clientItemFactory));
|
this.clientItemFactory.createItem.bind(this.clientItemFactory));
|
||||||
|
|
||||||
|
// If loading a savegame clients and unassigned players can't be replaced by a AI. Don't show
|
||||||
|
// the AIs in the dropdown.
|
||||||
|
const AIsSelectable = g_GameSettings.savegame.value === null ||
|
||||||
|
g_GameSettings.playerAI.get(this.playerIndex);
|
||||||
this.values = prepareForDropdown([
|
this.values = prepareForDropdown([
|
||||||
...this.playerItems,
|
...this.playerItems,
|
||||||
...this.aiItems,
|
...AIsSelectable ? this.aiItems : [],
|
||||||
this.unassignedItem
|
this.unassignedItem
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -161,7 +179,8 @@ PlayerSettingControls.PlayerAssignment.prototype.AutocompleteOrder = 100;
|
|||||||
if (ai)
|
if (ai)
|
||||||
g_GameSettings.playerAI.swap(sourcePlayer, playerIndex);
|
g_GameSettings.playerAI.swap(sourcePlayer, playerIndex);
|
||||||
// Swap color + civ as well - this allows easy reorganizing of player order.
|
// Swap color + civ as well - this allows easy reorganizing of player order.
|
||||||
if (g_GameSettings.map.type !== "scenario")
|
if (g_GameSettings.savegame.value === null &&
|
||||||
|
g_GameSettings.map.type !== "scenario")
|
||||||
{
|
{
|
||||||
g_GameSettings.playerCiv.swap(sourcePlayer, playerIndex);
|
g_GameSettings.playerCiv.swap(sourcePlayer, playerIndex);
|
||||||
g_GameSettings.playerColor.swap(sourcePlayer, playerIndex);
|
g_GameSettings.playerColor.swap(sourcePlayer, playerIndex);
|
||||||
|
@ -14,6 +14,11 @@ PlayerSettingControls.PlayerCiv = class PlayerCiv extends GameSettingControlDrop
|
|||||||
this.rebuild();
|
this.rebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSavegameChanged()
|
||||||
|
{
|
||||||
|
return !g_GameSettings.playerCiv.locked[this.playerIndex] && null;
|
||||||
|
}
|
||||||
|
|
||||||
setControl()
|
setControl()
|
||||||
{
|
{
|
||||||
this.label = Engine.GetGUIObjectByName("playerCivText[" + this.playerIndex + "]");
|
this.label = Engine.GetGUIObjectByName("playerCivText[" + this.playerIndex + "]");
|
||||||
|
@ -8,6 +8,11 @@ PlayerSettingControls.PlayerColor = class PlayerColor extends GameSettingControl
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSavegameChanged()
|
||||||
|
{
|
||||||
|
return g_GameSettings.map.type !== "scenario" && null;
|
||||||
|
}
|
||||||
|
|
||||||
setControl()
|
setControl()
|
||||||
{
|
{
|
||||||
this.dropdown = Engine.GetGUIObjectByName("playerColor[" + this.playerIndex + "]");
|
this.dropdown = Engine.GetGUIObjectByName("playerColor[" + this.playerIndex + "]");
|
||||||
|
@ -24,6 +24,11 @@ PlayerSettingControls.PlayerTeam = class PlayerTeam extends GameSettingControlDr
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSavegameChanged()
|
||||||
|
{
|
||||||
|
return g_GameSettings.map.type !== "scenario" && null;
|
||||||
|
}
|
||||||
|
|
||||||
setControl()
|
setControl()
|
||||||
{
|
{
|
||||||
this.label = Engine.GetGUIObjectByName("playerTeamText[" + this.playerIndex + "]");
|
this.label = Engine.GetGUIObjectByName("playerTeamText[" + this.playerIndex + "]");
|
||||||
|
@ -13,6 +13,13 @@ PlayerSettingControls.PlayerName = class PlayerName extends GameSettingControl
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSavegameChanged()
|
||||||
|
{
|
||||||
|
this.onPlayerAssignmentsChange();
|
||||||
|
g_GameSettings.playerName.maybeUpdate();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
onPlayerAssignmentsChange()
|
onPlayerAssignmentsChange()
|
||||||
{
|
{
|
||||||
this.guid = undefined;
|
this.guid = undefined;
|
||||||
|
@ -25,7 +25,7 @@ GameSettingControls.MapBrowser = class MapBrowser extends GameSettingControlButt
|
|||||||
|
|
||||||
onPress()
|
onPress()
|
||||||
{
|
{
|
||||||
this.setupWindow.pages.MapBrowserPage.openPage();
|
this.setupWindow.pages.MapBrowserPage.openPage(this.enabled);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,9 +15,14 @@ SetupWindowPages.GameSetupPage = class
|
|||||||
let startGameButton = new StartGameButton(setupWindow);
|
let startGameButton = new StartGameButton(setupWindow);
|
||||||
let readyButton = new ReadyButton(setupWindow);
|
let readyButton = new ReadyButton(setupWindow);
|
||||||
this.panelButtons = {
|
this.panelButtons = {
|
||||||
"cancelButton": new CancelButton(setupWindow, startGameButton, readyButton),
|
|
||||||
"civInfoButton": new CivInfoButton(),
|
"civInfoButton": new CivInfoButton(),
|
||||||
"lobbyButton": new LobbyButton(),
|
"lobbyButton": new LobbyButton(),
|
||||||
|
"clearSavegameButton": new ClearSavegameButton(
|
||||||
|
setupWindow.controls.gameSettingsController),
|
||||||
|
"loadSavegameButton": new LoadSavegameButton(
|
||||||
|
setupWindow.controls.gameSettingsController),
|
||||||
|
"savegameLabel": new SavegameLabel(),
|
||||||
|
"cancelButton": new CancelButton(setupWindow, startGameButton, readyButton),
|
||||||
"readyButton": readyButton,
|
"readyButton": readyButton,
|
||||||
"startGameButton": startGameButton
|
"startGameButton": startGameButton
|
||||||
};
|
};
|
||||||
@ -31,7 +36,7 @@ SetupWindowPages.GameSetupPage = class
|
|||||||
|
|
||||||
this.panels = {
|
this.panels = {
|
||||||
"chatPanel": new ChatPanel(setupWindow, this.gameSettingControlManager, gameSettingsPanel),
|
"chatPanel": new ChatPanel(setupWindow, this.gameSettingControlManager, gameSettingsPanel),
|
||||||
"gameSettingWarning": new GameSettingWarning(setupWindow, this.panelButtons.cancelButton),
|
"gameSettingWarning": new GameSettingWarning(setupWindow),
|
||||||
"gameDescription": new GameDescription(setupWindow, gameSettingTabs),
|
"gameDescription": new GameDescription(setupWindow, gameSettingTabs),
|
||||||
"gameSettingsPanel": gameSettingsPanel,
|
"gameSettingsPanel": gameSettingsPanel,
|
||||||
"gameSettingsTabs": gameSettingTabs,
|
"gameSettingsTabs": gameSettingTabs,
|
||||||
|
@ -67,23 +67,29 @@
|
|||||||
|
|
||||||
<object name="bottomLeftPanel">
|
<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"/>
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Tooltip.xml"/>
|
||||||
</object>
|
</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"/>
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/GameSettingWarning.xml"/>
|
||||||
</object>
|
</object>
|
||||||
|
|
||||||
</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%">
|
<object size="0 0 140 100%">
|
||||||
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/ClearSavegameButton.xml"/>
|
||||||
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/LoadSavegameButton.xml"/>
|
||||||
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/SavegameLabel.xml"/>
|
||||||
|
</object>
|
||||||
|
|
||||||
|
<object size="150 0 290 100%">
|
||||||
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/CancelButton.xml"/>
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/CancelButton.xml"/>
|
||||||
</object>
|
</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/ReadyButton.xml"/>
|
||||||
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/StartGameButton.xml"/>
|
<include file="gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/StartGameButton.xml"/>
|
||||||
</object>
|
</object>
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
class ClearSavegameButton
|
||||||
|
{
|
||||||
|
constructor(gameSettingsController)
|
||||||
|
{
|
||||||
|
const clearSavegameButton = Engine.GetGUIObjectByName("clearSavegameButton");
|
||||||
|
clearSavegameButton.onPress = () => {
|
||||||
|
g_GameSettings.savegame.value = null;
|
||||||
|
gameSettingsController.setNetworkInitAttributes();
|
||||||
|
};
|
||||||
|
if (g_IsNetworked && g_IsController)
|
||||||
|
{
|
||||||
|
g_GameSettings.savegame.watch(() => {
|
||||||
|
clearSavegameButton.hidden = g_GameSettings.savegame.value === null;
|
||||||
|
}, ["value"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<object
|
||||||
|
name="clearSavegameButton"
|
||||||
|
type="button"
|
||||||
|
style="StoneButton"
|
||||||
|
tooltip_style="onscreenToolTip"
|
||||||
|
hidden="true">
|
||||||
|
|
||||||
|
<translatableAttribute id="caption">Clear Save</translatableAttribute>
|
||||||
|
|
||||||
|
<translatableAttribute id="tooltip">
|
||||||
|
Discard the Savegame data and start a new match instead.
|
||||||
|
</translatableAttribute>
|
||||||
|
</object>
|
@ -0,0 +1,38 @@
|
|||||||
|
class LoadSavegameButton
|
||||||
|
{
|
||||||
|
constructor(gameSettingsController)
|
||||||
|
{
|
||||||
|
this.gameSettingsController = gameSettingsController;
|
||||||
|
|
||||||
|
const loadSavegameButton = Engine.GetGUIObjectByName("loadSavegameButton");
|
||||||
|
loadSavegameButton.onPress = this.loadSavegame.bind(this);
|
||||||
|
|
||||||
|
if (g_IsNetworked && g_IsController)
|
||||||
|
{
|
||||||
|
g_GameSettings.savegame.watch(() => {
|
||||||
|
loadSavegameButton.hidden = g_GameSettings.savegame.value !== null;
|
||||||
|
}, ["value"]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
loadSavegameButton.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSavegame()
|
||||||
|
{
|
||||||
|
const gameId = await Engine.PushGuiPage("page_loadgame.xml");
|
||||||
|
|
||||||
|
// If no data is being provided, for instance if the cancel button is
|
||||||
|
// pressed
|
||||||
|
if (gameId === undefined)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const metadata = Engine.LoadSavedGameMetadata(gameId);
|
||||||
|
|
||||||
|
// Remove the gaia entry.
|
||||||
|
metadata.initAttributes.settings.PlayerData.splice(0, 1);
|
||||||
|
|
||||||
|
g_GameSettings.fromInitAttributes(metadata.initAttributes);
|
||||||
|
g_GameSettings.savegame.value = gameId;
|
||||||
|
this.gameSettingsController.setNetworkInitAttributes();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<object
|
||||||
|
name="loadSavegameButton"
|
||||||
|
type="button"
|
||||||
|
style="StoneButton"
|
||||||
|
tooltip_style="onscreenToolTip">
|
||||||
|
|
||||||
|
<translatableAttribute id="caption">Load Save</translatableAttribute>
|
||||||
|
|
||||||
|
<translatableAttribute id="tooltip">
|
||||||
|
Load a previously created Savegame. You will still have to press start after having loaded the game data.
|
||||||
|
</translatableAttribute>
|
||||||
|
</object>
|
@ -9,11 +9,25 @@ class ResetCivsButton
|
|||||||
this.civResetButton.onPress = this.onPress.bind(this);
|
this.civResetButton.onPress = this.onPress.bind(this);
|
||||||
|
|
||||||
g_GameSettings.map.watch(() => this.render(), ["type"]);
|
g_GameSettings.map.watch(() => this.render(), ["type"]);
|
||||||
|
g_GameSettings.savegame.watch(() => {
|
||||||
|
const isSavegame = g_GameSettings.savegame.value !== null;
|
||||||
|
this.setEnabled(this.shouldBeEnabled() && !isSavegame);
|
||||||
|
}, ["value"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldBeEnabled()
|
||||||
|
{
|
||||||
|
return g_GameSettings.map.type !== "scenario" && g_IsController;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled)
|
||||||
|
{
|
||||||
|
this.civResetButton.hidden = !enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
{
|
{
|
||||||
this.civResetButton.hidden = g_GameSettings.map.type == "scenario" || !g_IsController;
|
this.setEnabled(this.shouldBeEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
onPress()
|
onPress()
|
||||||
|
@ -9,11 +9,25 @@ class ResetTeamsButton
|
|||||||
this.teamResetButton.onPress = this.onPress.bind(this);
|
this.teamResetButton.onPress = this.onPress.bind(this);
|
||||||
|
|
||||||
g_GameSettings.map.watch(() => this.render(), ["type"]);
|
g_GameSettings.map.watch(() => this.render(), ["type"]);
|
||||||
|
g_GameSettings.savegame.watch(() => {
|
||||||
|
const isSavegame = g_GameSettings.savegame.value !== null;
|
||||||
|
this.setEnabled(this.shouldBeEnabled() && !isSavegame);
|
||||||
|
}, ["value"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldBeEnabled()
|
||||||
|
{
|
||||||
|
return g_GameSettings.map.type !== "scenario" && g_IsController;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled)
|
||||||
|
{
|
||||||
|
this.teamResetButton.hidden = !enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
{
|
{
|
||||||
this.teamResetButton.hidden = g_GameSettings.map.type == "scenario" || !g_IsController;
|
this.setEnabled(this.shouldBeEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
onPress()
|
onPress()
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
class SavegameLabel
|
||||||
|
{
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
const savegameLabel = Engine.GetGUIObjectByName("savegameLabel");
|
||||||
|
if (g_IsNetworked && !g_IsController)
|
||||||
|
{
|
||||||
|
g_GameSettings.savegame.watch(() => {
|
||||||
|
savegameLabel.hidden = g_GameSettings.savegame.value === null;
|
||||||
|
}, ["value"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<object
|
||||||
|
name="savegameLabel"
|
||||||
|
type="text"
|
||||||
|
style="ModernLabelText"
|
||||||
|
tooltip_style="onscreenToolTip"
|
||||||
|
hidden="true">
|
||||||
|
|
||||||
|
<translatableAttribute id="caption">Savegame</translatableAttribute>
|
||||||
|
|
||||||
|
<translatableAttribute id="tooltip">
|
||||||
|
The controler loaded a savegame.
|
||||||
|
</translatableAttribute>
|
||||||
|
</object>
|
@ -17,7 +17,7 @@ class MapPreview
|
|||||||
|
|
||||||
onPress()
|
onPress()
|
||||||
{
|
{
|
||||||
this.setupWindow.pages.MapBrowserPage.openPage();
|
this.setupWindow.pages.MapBrowserPage.openPage(g_GameSettings.savegame.value === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderName()
|
renderName()
|
||||||
|
@ -25,9 +25,9 @@ SetupWindowPages.MapBrowserPage = class extends MapBrowser
|
|||||||
this.gameSettingsController.setNetworkInitAttributes();
|
this.gameSettingsController.setNetworkInitAttributes();
|
||||||
}
|
}
|
||||||
|
|
||||||
openPage()
|
openPage(enabled)
|
||||||
{
|
{
|
||||||
super.openPage(g_IsController);
|
super.openPage(g_IsController && enabled);
|
||||||
|
|
||||||
this.controls.MapFiltering.select(
|
this.controls.MapFiltering.select(
|
||||||
this.gameSettingsController.guiData.mapFilter.filter,
|
this.gameSettingsController.guiData.mapFilter.filter,
|
||||||
|
@ -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_TIMEOUT, NCS_PREGAME, &OnClientTimeout, this);
|
||||||
AddTransition(NCS_PREGAME, (uint)NMT_CLIENT_PERFORMANCE, NCS_PREGAME, &OnClientPerformance, 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_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_PREGAME, (uint)NMT_JOIN_SYNC_START, NCS_JOIN_SYNCING, &OnJoinSyncStart, this);
|
||||||
|
|
||||||
AddTransition(NCS_JOIN_SYNCING, (uint)NMT_CHAT, NCS_JOIN_SYNCING, &OnChat, 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_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_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_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_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_END_COMMAND_BATCH, NCS_JOIN_SYNCING, &OnJoinSyncEndCommandBatch, this);
|
||||||
AddTransition(NCS_JOIN_SYNCING, (uint)NMT_LOADED_GAME, NCS_INGAME, &OnLoadedGame, this);
|
AddTransition(NCS_JOIN_SYNCING, (uint)NMT_LOADED_GAME, NCS_INGAME, &OnLoadedGame, this);
|
||||||
@ -489,6 +491,15 @@ void CNetClient::SendStartGameMessage(const CStr& initAttribs)
|
|||||||
SendMessage(&gameStart);
|
SendMessage(&gameStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CNetClient::SendStartSavedGameMessage(const CStr& initAttribs, const CStr& savedState)
|
||||||
|
{
|
||||||
|
m_SavedState = savedState;
|
||||||
|
|
||||||
|
CGameSavedStartMessage gameSavedStart;
|
||||||
|
gameSavedStart.m_InitAttributes = initAttribs;
|
||||||
|
SendMessage(&gameSavedStart);
|
||||||
|
}
|
||||||
|
|
||||||
void CNetClient::SendRejoinedMessage()
|
void CNetClient::SendRejoinedMessage()
|
||||||
{
|
{
|
||||||
CRejoinedMessage rejoinedMessage;
|
CRejoinedMessage rejoinedMessage;
|
||||||
@ -524,25 +535,29 @@ bool CNetClient::HandleMessage(CNetMessage* message)
|
|||||||
{
|
{
|
||||||
CFileTransferRequestMessage* reqMessage = static_cast<CFileTransferRequestMessage*>(message);
|
CFileTransferRequestMessage* reqMessage = static_cast<CFileTransferRequestMessage*>(message);
|
||||||
|
|
||||||
ENSURE(static_cast<CNetFileTransferer::RequestType>(reqMessage->m_RequestType) ==
|
std::string toCompress{[&]
|
||||||
CNetFileTransferer::RequestType::REJOIN);
|
{
|
||||||
|
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
|
std::stringstream stream;
|
||||||
// it's always requesting the simulation state
|
|
||||||
|
|
||||||
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());
|
bool ok = m_Game->GetSimulation2()->SerializeState(stream);
|
||||||
u32 turn = to_le32(m_ClientTurnManager->GetCurrentTurn());
|
ENSURE(ok);
|
||||||
stream.write((char*)&turn, sizeof(turn));
|
return stream.str();
|
||||||
|
}()};
|
||||||
bool ok = m_Game->GetSimulation2()->SerializeState(stream);
|
|
||||||
ENSURE(ok);
|
|
||||||
|
|
||||||
// Compress the content with zlib to save bandwidth
|
// Compress the content with zlib to save bandwidth
|
||||||
// (TODO: if this is still too large, compressing with e.g. LZMA works much better)
|
// (TODO: if this is still too large, compressing with e.g. LZMA works much better)
|
||||||
std::string compressed;
|
std::string compressed;
|
||||||
CompressZLib(stream.str(), compressed, true);
|
CompressZLib(toCompress, compressed, true);
|
||||||
|
|
||||||
m_Session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, compressed);
|
m_Session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, compressed);
|
||||||
|
|
||||||
@ -605,6 +620,18 @@ void CNetClient::SendAuthenticateMessage()
|
|||||||
SendMessage(&authenticate);
|
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)
|
bool CNetClient::OnConnect(CNetClient* client, CFsmEvent* event)
|
||||||
{
|
{
|
||||||
ENSURE(event->GetType() == (uint)NMT_CONNECT_COMPLETE);
|
ENSURE(event->GetType() == (uint)NMT_CONNECT_COMPLETE);
|
||||||
@ -759,29 +786,40 @@ bool CNetClient::OnGameStart(CNetClient* client, CFsmEvent* event)
|
|||||||
|
|
||||||
CGameStartMessage* message = static_cast<CGameStartMessage*>(event->GetParamRef());
|
CGameStartMessage* message = static_cast<CGameStartMessage*>(event->GetParamRef());
|
||||||
|
|
||||||
// Find the player assigned to our GUID
|
const ScriptInterface& scriptInterface{client->m_Game->GetSimulation2()->GetScriptInterface()};
|
||||||
int player = -1;
|
ScriptRequest rq{scriptInterface};
|
||||||
if (client->m_PlayerAssignments.find(client->m_GUID) != client->m_PlayerAssignments.end())
|
JS::RootedValue initAttribs{rq.cx};
|
||||||
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);
|
|
||||||
Script::ParseJSON(rq, message->m_InitAttributes, &initAttribs);
|
Script::ParseJSON(rq, message->m_InitAttributes, &initAttribs);
|
||||||
|
|
||||||
client->m_Game->SetPlayerID(player);
|
client->PushGuiMessage("type", "start", "initAttributes", initAttribs);
|
||||||
client->m_Game->StartGame(&initAttribs, "");
|
client->StartGame(&initAttribs, "");
|
||||||
|
|
||||||
client->PushGuiMessage("type", "start",
|
|
||||||
"initAttributes", initAttribs);
|
|
||||||
|
|
||||||
return true;
|
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)
|
bool CNetClient::OnJoinSyncStart(CNetClient* client, CFsmEvent* event)
|
||||||
{
|
{
|
||||||
ENSURE(event->GetType() == (uint)NMT_JOIN_SYNC_START);
|
ENSURE(event->GetType() == (uint)NMT_JOIN_SYNC_START);
|
||||||
|
@ -236,6 +236,8 @@ public:
|
|||||||
|
|
||||||
void SendStartGameMessage(const CStr& initAttribs);
|
void SendStartGameMessage(const CStr& initAttribs);
|
||||||
|
|
||||||
|
void SendStartSavedGameMessage(const CStr& initAttribs, const CStr& savedState);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call when the client has rejoined a running match and finished
|
* Call when the client has rejoined a running match and finished
|
||||||
* the loading screen.
|
* the loading screen.
|
||||||
@ -277,6 +279,7 @@ private:
|
|||||||
static bool OnPlayerAssignment(CNetClient* client, CFsmEvent* event);
|
static bool OnPlayerAssignment(CNetClient* client, CFsmEvent* event);
|
||||||
static bool OnInGame(CNetClient* client, CFsmEvent* event);
|
static bool OnInGame(CNetClient* client, CFsmEvent* event);
|
||||||
static bool OnGameStart(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 OnJoinSyncStart(CNetClient* client, CFsmEvent* event);
|
||||||
static bool OnJoinSyncEndCommandBatch(CNetClient* client, CFsmEvent* event);
|
static bool OnJoinSyncEndCommandBatch(CNetClient* client, CFsmEvent* event);
|
||||||
static bool OnRejoined(CNetClient* client, CFsmEvent* event);
|
static bool OnRejoined(CNetClient* client, CFsmEvent* event);
|
||||||
@ -292,6 +295,12 @@ private:
|
|||||||
*/
|
*/
|
||||||
void SetAndOwnSession(CNetClientSession* session);
|
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.
|
* Push a message onto the GUI queue listing the current player assignments.
|
||||||
*/
|
*/
|
||||||
@ -342,6 +351,8 @@ private:
|
|||||||
/// Serialized game state received when joining an in-progress game
|
/// Serialized game state received when joining an in-progress game
|
||||||
std::string m_JoinSyncBuffer;
|
std::string m_JoinSyncBuffer;
|
||||||
|
|
||||||
|
std::string m_SavedState;
|
||||||
|
|
||||||
/// Time when the server was last checked for timeouts and bad latency
|
/// Time when the server was last checked for timeouts and bad latency
|
||||||
std::time_t m_LastConnectionCheck;
|
std::time_t m_LastConnectionCheck;
|
||||||
};
|
};
|
||||||
|
@ -183,6 +183,10 @@ CNetMessage* CNetMessageFactory::CreateMessage(const void* pData,
|
|||||||
pNewMessage = new CGameStartMessage;
|
pNewMessage = new CGameStartMessage;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case NMT_SAVED_GAME_START:
|
||||||
|
pNewMessage = new CGameSavedStartMessage;
|
||||||
|
break;
|
||||||
|
|
||||||
case NMT_END_COMMAND_BATCH:
|
case NMT_END_COMMAND_BATCH:
|
||||||
pNewMessage = new CEndCommandBatchMessage;
|
pNewMessage = new CEndCommandBatchMessage;
|
||||||
break;
|
break;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* Copyright (C) 2021 Wildfire Games.
|
/* Copyright (C) 2024 Wildfire Games.
|
||||||
* This file is part of 0 A.D.
|
* This file is part of 0 A.D.
|
||||||
*
|
*
|
||||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||||
@ -75,6 +75,7 @@ enum NetMessageType
|
|||||||
|
|
||||||
NMT_LOADED_GAME,
|
NMT_LOADED_GAME,
|
||||||
NMT_GAME_START,
|
NMT_GAME_START,
|
||||||
|
NMT_SAVED_GAME_START,
|
||||||
NMT_END_COMMAND_BATCH,
|
NMT_END_COMMAND_BATCH,
|
||||||
|
|
||||||
NMT_SYNC_CHECK, // OOS-detection hash checking
|
NMT_SYNC_CHECK, // OOS-detection hash checking
|
||||||
@ -218,6 +219,10 @@ START_NMT_CLASS_(GameStart, NMT_GAME_START)
|
|||||||
NMT_FIELD(CStr, m_InitAttributes)
|
NMT_FIELD(CStr, m_InitAttributes)
|
||||||
END_NMT_CLASS()
|
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)
|
START_NMT_CLASS_(EndCommandBatch, NMT_END_COMMAND_BATCH)
|
||||||
NMT_FIELD_INT(m_Turn, u32, 4)
|
NMT_FIELD_INT(m_Turn, u32, 4)
|
||||||
NMT_FIELD_INT(m_TurnLength, u32, 2)
|
NMT_FIELD_INT(m_TurnLength, u32, 2)
|
||||||
|
@ -623,14 +623,15 @@ void CNetServerWorker::HandleMessageReceive(const CNetMessage* message, CNetServ
|
|||||||
if (message->GetType() == NMT_FILE_TRANSFER_REQUEST)
|
if (message->GetType() == NMT_FILE_TRANSFER_REQUEST)
|
||||||
{
|
{
|
||||||
CFileTransferRequestMessage* reqMessage = (CFileTransferRequestMessage*)message;
|
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
|
// A client requested the gamestate. Clients only request the gamestate when we sent them a
|
||||||
// another client, and has now requested that we forward it to them
|
// JoinSyncStart or a GameSavedStart message. We only send thous 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,
|
||||||
session->GetFileTransferer().StartResponse(reqMessage->m_RequestID, m_JoinSyncFile);
|
static_cast<CNetFileTransferer::RequestType>(reqMessage->m_RequestType) ==
|
||||||
|
CNetFileTransferer::RequestType::LOADGAME ? m_SavedState : m_JoinSyncFile);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -663,6 +664,7 @@ void CNetServerWorker::SetupSession(CNetServerSession* session)
|
|||||||
session->AddTransition(NSS_PREGAME, (uint)NMT_ASSIGN_PLAYER, NSS_PREGAME, &OnAssignPlayer, 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_KICKED, NSS_PREGAME, &OnKickPlayer, session);
|
||||||
session->AddTransition(NSS_PREGAME, (uint)NMT_GAME_START, NSS_PREGAME, &OnGameStart, 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_PREGAME, (uint)NMT_LOADED_GAME, NSS_INGAME, &OnLoadedGame, session);
|
||||||
|
|
||||||
session->AddTransition(NSS_JOIN_SYNCING, (uint)NMT_KICKED, NSS_JOIN_SYNCING, &OnKickPlayer, session);
|
session->AddTransition(NSS_JOIN_SYNCING, (uint)NMT_KICKED, NSS_JOIN_SYNCING, &OnKickPlayer, session);
|
||||||
@ -1318,6 +1320,25 @@ bool CNetServerWorker::OnGameStart(CNetServerSession* session, CFsmEvent* event)
|
|||||||
return true;
|
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)
|
bool CNetServerWorker::OnLoadedGame(CNetServerSession* loadedSession, CFsmEvent* event)
|
||||||
{
|
{
|
||||||
ENSURE(event->GetType() == (uint)NMT_LOADED_GAME);
|
ENSURE(event->GetType() == (uint)NMT_LOADED_GAME);
|
||||||
@ -1341,6 +1362,10 @@ bool CNetServerWorker::OnLoadedGame(CNetServerSession* loadedSession, CFsmEvent*
|
|||||||
message.m_Clients.push_back(client);
|
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
|
// Send to the client who has loaded the game but did not reach the NSS_INGAME state yet
|
||||||
loadedSession->SendMessage(&message);
|
loadedSession->SendMessage(&message);
|
||||||
server.Broadcast(&message, { NSS_INGAME });
|
server.Broadcast(&message, { NSS_INGAME });
|
||||||
@ -1502,7 +1527,7 @@ bool CNetServerWorker::CheckGameLoadStatus(CNetServerSession* changedSession)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CNetServerWorker::StartGame(const CStr& initAttribs)
|
void CNetServerWorker::PreStartGame(const CStr& initAttribs)
|
||||||
{
|
{
|
||||||
for (std::pair<const CStr, PlayerAssignment>& player : m_PlayerAssignments)
|
for (std::pair<const CStr, PlayerAssignment>& player : m_PlayerAssignments)
|
||||||
if (player.second.m_Enabled && player.second.m_PlayerID != -1 && player.second.m_Status == 0)
|
if (player.second.m_Enabled && player.second.m_PlayerID != -1 && player.second.m_Status == 0)
|
||||||
@ -1533,12 +1558,26 @@ void CNetServerWorker::StartGame(const CStr& initAttribs)
|
|||||||
|
|
||||||
// Update init attributes. They should no longer change.
|
// Update init attributes. They should no longer change.
|
||||||
Script::ParseJSON(ScriptRequest(m_ScriptInterface), initAttribs, &m_InitAttributes);
|
Script::ParseJSON(ScriptRequest(m_ScriptInterface), initAttribs, &m_InitAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CNetServerWorker::StartGame(const CStr& initAttribs)
|
||||||
|
{
|
||||||
|
PreStartGame(initAttribs);
|
||||||
|
|
||||||
CGameStartMessage gameStart;
|
CGameStartMessage gameStart;
|
||||||
gameStart.m_InitAttributes = initAttribs;
|
gameStart.m_InitAttributes = initAttribs;
|
||||||
Broadcast(&gameStart, { NSS_PREGAME });
|
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)
|
CStrW CNetServerWorker::SanitisePlayerName(const CStrW& original)
|
||||||
{
|
{
|
||||||
const size_t MAX_LENGTH = 32;
|
const size_t MAX_LENGTH = 32;
|
||||||
|
@ -255,11 +255,22 @@ private:
|
|||||||
*/
|
*/
|
||||||
void AssignPlayer(int playerID, const CStr& guid);
|
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.
|
* Switch in game mode and notify all clients to start the game.
|
||||||
*/
|
*/
|
||||||
void StartGame(const CStr& initAttribs);
|
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.
|
* Make a player name 'nicer' by limiting the length and removing forbidden characters etc.
|
||||||
*/
|
*/
|
||||||
@ -305,6 +316,7 @@ private:
|
|||||||
static bool OnGameSetup(CNetServerSession* session, CFsmEvent* event);
|
static bool OnGameSetup(CNetServerSession* session, CFsmEvent* event);
|
||||||
static bool OnAssignPlayer(CNetServerSession* session, CFsmEvent* event);
|
static bool OnAssignPlayer(CNetServerSession* session, CFsmEvent* event);
|
||||||
static bool OnGameStart(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 OnLoadedGame(CNetServerSession* session, CFsmEvent* event);
|
||||||
static bool OnJoinSyncingLoadedGame(CNetServerSession* session, CFsmEvent* event);
|
static bool OnJoinSyncingLoadedGame(CNetServerSession* session, CFsmEvent* event);
|
||||||
static bool OnRejoined(CNetServerSession* session, CFsmEvent* event);
|
static bool OnRejoined(CNetServerSession* session, CFsmEvent* event);
|
||||||
@ -399,6 +411,11 @@ private:
|
|||||||
*/
|
*/
|
||||||
std::string m_JoinSyncFile;
|
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.
|
* Time when the clients connections were last checked for timeouts and latency.
|
||||||
*/
|
*/
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* Copyright (C) 2022 Wildfire Games.
|
/* Copyright (C) 2024 Wildfire Games.
|
||||||
* This file is part of 0 A.D.
|
* This file is part of 0 A.D.
|
||||||
*
|
*
|
||||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||||
@ -33,6 +33,7 @@
|
|||||||
#include "ps/GUID.h"
|
#include "ps/GUID.h"
|
||||||
#include "ps/Hashing.h"
|
#include "ps/Hashing.h"
|
||||||
#include "ps/Pyrogenesis.h"
|
#include "ps/Pyrogenesis.h"
|
||||||
|
#include "ps/SavedGame.h"
|
||||||
#include "ps/Util.h"
|
#include "ps/Util.h"
|
||||||
#include "scriptinterface/FunctionWrapper.h"
|
#include "scriptinterface/FunctionWrapper.h"
|
||||||
#include "scriptinterface/StructuredClone.h"
|
#include "scriptinterface/StructuredClone.h"
|
||||||
@ -260,10 +261,33 @@ void StartNetworkGame(const ScriptInterface& scriptInterface, JS::HandleValue at
|
|||||||
{
|
{
|
||||||
ENSURE(g_NetClient);
|
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);
|
ScriptRequest rq(scriptInterface);
|
||||||
|
|
||||||
|
JS::RootedValue settings{rq.cx};
|
||||||
|
if (!Script::GetProperty(rq, attribs1, "settings", &settings))
|
||||||
|
{
|
||||||
|
ScriptException::Raise(rq, "The initAttributes didn't contain a \"settings\" property.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
JS::RootedValue attribs(rq.cx, attribs1);
|
JS::RootedValue attribs(rq.cx, attribs1);
|
||||||
g_NetClient->SendStartGameMessage(Script::StringifyJSON(rq, &attribs));
|
std::string attributesAsString{Script::StringifyJSON(rq, &attribs)};
|
||||||
|
|
||||||
|
std::wstring savegameID;
|
||||||
|
if (Script::HasProperty(rq, settings, "Savegame") &&
|
||||||
|
Script::GetProperty(rq, settings, "Savegame", savegameID))
|
||||||
|
{
|
||||||
|
const std::optional<SavedGames::LoadResult> loadResult{
|
||||||
|
SavedGames::Load(scriptInterface, savegameID)};
|
||||||
|
if (!loadResult)
|
||||||
|
{
|
||||||
|
ScriptException::Raise(rq, "Failed to load the saved game: \"%ls\"", savegameID.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_NetClient->SendStartSavedGameMessage(attributesAsString, loadResult->savedState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_NetClient->SendStartGameMessage(attributesAsString);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetTurnLength(int length)
|
void SetTurnLength(int length)
|
||||||
|
@ -204,7 +204,8 @@ private:
|
|||||||
std::string* m_SavedState;
|
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
|
// Determine the filename to load
|
||||||
const VfsPath basename(L"saves/" + name);
|
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
|
// Don't crash just because file isn't found, this can happen if the file is deleted from the OS
|
||||||
if (!VfsFileExists(filename))
|
if (!VfsFileExists(filename))
|
||||||
return ERR::FILE_NOT_FOUND;
|
return std::nullopt;
|
||||||
|
|
||||||
OsPath realPath;
|
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);
|
PIArchiveReader archiveReader = CreateArchiveReader_Zip(realPath);
|
||||||
if (!archiveReader)
|
if (!archiveReader)
|
||||||
WARN_RETURN(ERR::FAIL);
|
{
|
||||||
|
DEBUG_WARN_ERR(ERR::FAIL);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string savedState;
|
||||||
CGameLoader loader(scriptInterface, &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)
|
JS::Value SavedGames::GetSavedGames(const ScriptInterface& scriptInterface)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* Copyright (C) 2021 Wildfire Games.
|
/* Copyright (C) 2024 Wildfire Games.
|
||||||
* This file is part of 0 A.D.
|
* This file is part of 0 A.D.
|
||||||
*
|
*
|
||||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||||
@ -19,8 +19,11 @@
|
|||||||
#define INCLUDED_SAVEDGAME
|
#define INCLUDED_SAVEDGAME
|
||||||
|
|
||||||
#include "ps/CStr.h"
|
#include "ps/CStr.h"
|
||||||
|
#include "scriptinterface/ScriptTypes.h"
|
||||||
#include "scriptinterface/StructuredClone.h"
|
#include "scriptinterface/StructuredClone.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
class CSimulation2;
|
class CSimulation2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,18 +62,24 @@ namespace SavedGames
|
|||||||
*/
|
*/
|
||||||
Status SavePrefix(const CStrW& prefix, const CStrW& description, CSimulation2& simulation, const Script::StructuredClone& guiMetadataClone);
|
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
|
* Load saved game archive with the given name
|
||||||
*
|
*
|
||||||
* @param name filename of saved game (without path or extension)
|
* @param name filename of saved game (without path or extension)
|
||||||
* @param scriptInterface
|
* @param scriptInterface
|
||||||
* @param[out] metadata object containing metadata associated with saved game,
|
* @return An empty `std::optional` if an error ocoured.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
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
|
* Get list of saved games for GUI script usage
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* Copyright (C) 2021 Wildfire Games.
|
/* Copyright (C) 2024 Wildfire Games.
|
||||||
* This file is part of 0 A.D.
|
* This file is part of 0 A.D.
|
||||||
*
|
*
|
||||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||||
@ -29,6 +29,8 @@
|
|||||||
#include "simulation2/Simulation2.h"
|
#include "simulation2/Simulation2.h"
|
||||||
#include "simulation2/system/TurnManager.h"
|
#include "simulation2/system/TurnManager.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
namespace JSI_SavedGame
|
namespace JSI_SavedGame
|
||||||
{
|
{
|
||||||
JS::Value GetSavedGames(const ScriptInterface& scriptInterface)
|
JS::Value GetSavedGames(const ScriptInterface& scriptInterface)
|
||||||
@ -75,6 +77,13 @@ void QuickLoad()
|
|||||||
LOGERROR("Can't load quicksave if game is not running!");
|
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 ? JS::UndefinedValue() : data->metadata;
|
||||||
|
}
|
||||||
|
|
||||||
JS::Value StartSavedGame(const ScriptInterface& scriptInterface, const std::wstring& name)
|
JS::Value StartSavedGame(const ScriptInterface& scriptInterface, const std::wstring& name)
|
||||||
{
|
{
|
||||||
// We need to be careful with different compartments and contexts.
|
// 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);
|
ENSURE(!g_Game);
|
||||||
|
|
||||||
// Load the saved game data from disk
|
std::optional<SavedGames::LoadResult> data{SavedGames::Load(scriptInterface, name)};
|
||||||
JS::RootedValue guiContextMetadata(rqGui.cx);
|
if (!data)
|
||||||
std::string savedState;
|
|
||||||
Status err = SavedGames::Load(name, scriptInterface, &guiContextMetadata, savedState);
|
|
||||||
if (err < 0)
|
|
||||||
return JS::UndefinedValue();
|
return JS::UndefinedValue();
|
||||||
|
|
||||||
|
JS::RootedValue guiContextMetadata{rqGui.cx, data->metadata};
|
||||||
|
|
||||||
g_Game = new CGame(true);
|
g_Game = new CGame(true);
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -109,7 +117,7 @@ JS::Value StartSavedGame(const ScriptInterface& scriptInterface, const std::wstr
|
|||||||
Script::GetProperty(rqGame, gameContextMetadata, "playerID", playerID);
|
Script::GetProperty(rqGame, gameContextMetadata, "playerID", playerID);
|
||||||
|
|
||||||
g_Game->SetPlayerID(playerID);
|
g_Game->SetPlayerID(playerID);
|
||||||
g_Game->StartGame(&gameInitAttributes, savedState);
|
g_Game->StartGame(&gameInitAttributes, data->savedState);
|
||||||
}
|
}
|
||||||
|
|
||||||
return guiContextMetadata;
|
return guiContextMetadata;
|
||||||
@ -131,6 +139,7 @@ void RegisterScriptFunctions(const ScriptRequest& rq)
|
|||||||
ScriptFunction::Register<&QuickSave>(rq, "QuickSave");
|
ScriptFunction::Register<&QuickSave>(rq, "QuickSave");
|
||||||
ScriptFunction::Register<&QuickLoad>(rq, "QuickLoad");
|
ScriptFunction::Register<&QuickLoad>(rq, "QuickLoad");
|
||||||
ScriptFunction::Register<&ActivateRejoinTest>(rq, "ActivateRejoinTest");
|
ScriptFunction::Register<&ActivateRejoinTest>(rq, "ActivateRejoinTest");
|
||||||
|
ScriptFunction::Register<&LoadSavedGameMetadata>(rq, "LoadSavedGameMetadata");
|
||||||
ScriptFunction::Register<&StartSavedGame>(rq, "StartSavedGame");
|
ScriptFunction::Register<&StartSavedGame>(rq, "StartSavedGame");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user