Source: gamesetup/Controllers/GameSettingsController.js

/**
 * 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);
		}
		// 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;