Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js (revision 27313) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js (revision 27314) @@ -1,305 +1,317 @@ /** * Controller for the GUI handling of gamesettings. */ class GameSettingsController { constructor(setupWindow, netMessages, playerAssignmentsController, mapCache) { this.setupWindow = setupWindow; this.mapCache = mapCache; this.persistentMatchSettings = new PersistentMatchSettings(g_IsNetworked); this.guiData = new GameSettingsGuiData(); // When joining a game, the complete set of attributes // may not have been received yet. this.loading = true; // If this is true, the ready controller won't reset readiness. // TODO: ideally the ready controller would be somewhat independent from this one, // possibly by listening to gamesetup messages itself. this.gameStarted = false; this.updateLayoutHandlers = new Set(); this.settingsChangeHandlers = new Set(); this.loadingChangeHandlers = new Set(); this.settingsLoadedHandlers = new Set(); setupWindow.registerLoadHandler(this.onLoad.bind(this)); setupWindow.registerGetHotloadDataHandler(this.onGetHotloadData.bind(this)); setupWindow.registerClosePageHandler(this.onClose.bind(this)); if (g_IsNetworked) { if (g_IsController) playerAssignmentsController.registerClientJoinHandler(this.onClientJoin.bind(this)); else // In MP, the host launches the game and switches right away, // clients switch when they receive the appropriate message. netMessages.registerNetMessageHandler("start", this.switchToLoadingPage.bind(this)); netMessages.registerNetMessageHandler("gamesetup", this.onGamesetupMessage.bind(this)); } } /** * @param handler will be called when the layout needs to be updated. */ registerUpdateLayoutHandler(handler) { this.updateLayoutHandlers.add(handler); } /** * @param handler will be called when any setting change. * (this isn't exactly what happens but the behaviour should be similar). */ registerSettingsChangeHandler(handler) { this.settingsChangeHandlers.add(handler); } /** * @param handler will be called when the 'loading' state change. */ registerLoadingChangeHandler(handler) { this.loadingChangeHandlers.add(handler); } /** * @param handler will be called when the initial settings have been loaded. */ registerSettingsLoadedHandler(handler) { this.settingsLoadedHandlers.add(handler); } onLoad(initData, hotloadData) { - 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); + // 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) } + // If the new settings led to AI & players conflict, remove the AI. for (const guid in g_PlayerAssignments) if (g_PlayerAssignments[guid].player !== -1 && g_GameSettings.playerAI.get(g_PlayerAssignments[guid].player - 1)) g_GameSettings.playerAI.set(g_PlayerAssignments[guid].player - 1, undefined); for (const handler of this.settingsLoadedHandlers) handler(); this.updateLayout(); this.setNetworkInitAttributes(); // If we are the controller, we are done loading. if (hotloadData || !g_IsNetworked || g_IsController) this.setLoading(false); } onClientJoin() { /** * A note on network synchronization: * The net server does not keep the current state of attributes, * nor does it act like a message queue, so a new client * will only receive updates after they've joined. * In particular, new joiners start with no information, * so the controller must first send them a complete copy of the settings. * However, messages could be in-flight towards the controller, * but the new client may never receive these or have already received them, * leading to an ordering issue that might desync the new client. * * The simplest solution is to have the (single) controller * act as the single source of truth. Any other message must * first go through the controller, which will send updates. * This enforces the ordering of the controller. * In practical terms, if e.g. players controlling their own civ is implemented, * the message will need to be ignored by everyone but the controller, * and the controller will need to send an update once it rejects/accepts the changes, * which will then update the other clients. * Of course, the original client GUI may want to temporarily show a different state. * Note that the final attributes are sent on game start anyways, so any * synchronization issue that might happen at that point can be resolved. */ Engine.SendGameSetupMessage({ "type": "initial-update", "initAttribs": this.getSettings() }); } onGetHotloadData(object) { object.initAttributes = this.getSettings(); } onGamesetupMessage(message) { // For now, the controller only can send updates, so no need to listen to messages. if (!message.data || g_IsController) return; if (message.data.type !== "update" && message.data.type !== "initial-update") { error("Unknown message type " + message.data.type); return; } if (message.data.type === "initial-update") { // Ignore initial updates if we've already received settings. if (!this.loading) return; this.setLoading(false); } this.parseSettings(message.data.initAttribs); // This assumes that messages aren't sent spuriously without changes // (which is generally fair), but technically it would be good // to check if the new data is different from the previous data. for (let handler of this.settingsChangeHandlers) handler(); } /** * Returns the InitAttributes, augmented by GUI-specific data. */ getSettings() { let ret = g_GameSettings.toInitAttributes(); ret.guiData = this.guiData.Serialize(); return ret; } /** * Parse the following settings. */ parseSettings(settings) { if (settings.guiData) this.guiData.Deserialize(settings.guiData); g_GameSettings.fromInitAttributes(settings); } setLoading(loading) { if (this.loading === loading) return; this.loading = loading; for (let handler of this.loadingChangeHandlers) handler(loading); } /** * This should be called whenever the GUI layout needs to be updated. * Triggers on the next GUI tick to avoid un-necessary layout. */ updateLayout() { if (this.layoutTimer) return; this.layoutTimer = setTimeout(() => { for (let handler of this.updateLayoutHandlers) handler(); delete this.layoutTimer; }, 0); } /** * This function is to be called when a GUI control has initiated a value change. * * To avoid an infinite loop, do not call this function when a game setup message was * received and the data had only been modified deterministically. * * This is run on a timer to avoid flooding the network with messages, * e.g. when modifying a slider. */ setNetworkInitAttributes() { for (let handler of this.settingsChangeHandlers) handler(); if (g_IsNetworked && this.timer === undefined) this.timer = setTimeout(this.setNetworkInitAttributesImmediately.bind(this), this.Timeout); } setNetworkInitAttributesImmediately() { if (this.timer) { clearTimeout(this.timer); delete this.timer; } // See note in onClientJoin on network synchronization. if (g_IsController) Engine.SendGameSetupMessage({ "type": "update", "initAttribs": this.getSettings() }); } /** * Cheat prevention: * * 1. Ensure that the host cannot start the game unless all clients agreed on the game settings using the ready system. * * TODO: * 2. Ensure that the host cannot start the game with InitAttributes different from the agreed ones. * This may be achieved by: * - Determining the seed collectively. * - passing the agreed game settings to the engine when starting the game instance * - rejecting new game settings from the server after the game launch event */ launchGame() { // Save the file before random settings are resolved. this.savePersistentMatchSettings(); // Mark the game as started so the readyController won't reset state. this.gameStarted = true; // This will resolve random settings & send game start messages. // TODO: this will trigger observers, which is somewhat wasteful. g_GameSettings.launchGame(g_PlayerAssignments, true); // Switch to the loading page right away, // the GUI will otherwise show the unrandomised settings. this.switchToLoadingPage(); } switchToLoadingPage(attributes) { Engine.SwitchGuiPage("page_loading.xml", { "attribs": attributes?.initAttributes || g_GameSettings.finalizedAttributes, "playerAssignments": g_PlayerAssignments }); } onClose() { this.savePersistentMatchSettings(); } savePersistentMatchSettings() { if (g_IsController) // TODO: ought to only save a subset of settings. this.persistentMatchSettings.saveFile(this.getSettings()); } } /** * Wait (at most) this many milliseconds before sending network messages. */ GameSettingsController.prototype.Timeout = 400;