UNPKG

haxroomie-core

Version:

Node.js API for running and controlling HaxBall rooms.

671 lines (617 loc) 20.3 kB
const logger = require('../logger'); const { UnusableError, RoomNotRunningError, RoomIsRunningError, RoomLockedError, HHMNotLoadedError, } = require('../errors'); const EventEmitter = require('events'); const RoomOpener = require('./components/RoomOpener'); const RepositoryController = require('./components/RepositoryController'); const PluginController = require('./components/PluginController'); const RoleController = require('./components/RoleController'); const RoomErrorHandler = require('./components/RoomErrorHandler'); const RoomConsoleHandler = require('./components/RoomConsoleHandler'); const { stringify } = require('../utils'); /** * Event argument object that gets sent from the browser when a room event happens. * * The `handlerName` can be one of the following: * `onPlayerJoin` * `onPlayerLeave` * `onTeamVictory` * `onPlayerChat` * `onTeamGoal` * `onGameStart` * `onGameStop` * `onPlayerAdminChange` * `onPlayerTeamChange` * `onPlayerKicked` * `onGamePause` * `onGameUnpause` * `onPositionsReset` * or * `onStadiumChange` * * See the * [roomObject documentation](https://github.com/haxball/haxball-issues/wiki/Headless-Host#roomobject) * to find out what kind of arguments to expect. * * @typedef {Object} RoomEventArgs * @property {string} handlerName - Name of the haxball room event handler * function that got triggered. * @property {Array.<any>} args - Arguments that the event handler function * received. */ /** * Data object sent from the browser context. * * Used internally to communicate with the headless browser. * * Follows the flux standard action form loosely. * See (https://github.com/redux-utilities/flux-standard-action). * * @typedef {Object} BrowserAction * @property {string} type - The type of data identifies to the consumer the * nature of the data that was sent. * @property {any} [payload] - The optional payload property MAY be any type * of value. * @property {boolean} [error] - The optional error property MAY be set to true * if the data represents an error. */ /** * Represents a file. * * @typedef {Object} File * @property {string} name - Files name. * @property {string} content - UTF-8 encoded contents of the file. */ /** * Emitted when the browser tab gets closed. * Renders this RoomController unusable. * @event RoomController#page-closed * @param {RoomController} room - Instance of RoomController that was * controlling the page. */ /** * Emitted when the browser tab crashes. * Renders this RoomController unusable. * @event RoomController#page-crash * @param {Error} error - The error that was thrown. */ /** * Emitted when some script throws an error in the browsers tab. * @event RoomController#page-error * @param {Error} error - The error that was thrown. */ /** * Emitted when a browser tab logs an error to the console. * @event RoomController#error-logged * @param {string} message - The logged error message. */ /** * Emitted when a browser tab logs a warning to the console. * @event RoomController#warning-logged * @param {string} message - The logged warning message. */ /** * Emitted when a browser tab logs to console. * @event RoomController#info-logged * @param {string} message - The logged message. */ /** * Emitted when {@link RoomController#openRoom} has been called. * @event RoomController#open-room-start * @param {Error|UnusableError|RoomIsRunningError|RoomLockedError} [error] - If error happened when starting to open room. * @param {object} config - Config object given as argument to * {@link RoomController#openRoom} */ /** * Emitted when {@link RoomController#openRoom} has finished and the room * is running. * e.g. * ```js * room.on('open-room-stop', (err, roomInfo)=> { * if (err) { * console.log('Room did not open.', err); * } else { * console.log('Room was opened', roomInfo); * } * }); * room.openRoom(config); * ``` * @event RoomController#open-room-stop * @param {Error|ConnectionError|TimeoutError|InvalidTokenError} [error] * - If error happened when opening the room. * @param {object} roomInfo - Information about the room. */ /** * Emitted when {@link RoomController#closeRoom} has been called. * @param {UnusableError} [error] - If the room is at unusable state. * @event RoomController#close-room-start */ /** * Emitted when {@link RoomController#closeRoom} has finished. * @param {Error} [error] - If error happened during closeRoom. * @event RoomController#close-room-stop */ /** * Emitted when supported HaxBall roomObject event happens. * @event RoomController#room-event * @param {RoomEventArgs} roomEventArgs - Event arguments. */ /** * Emitted when a plugin is loaded. * @event RoomController#plugin-loaded * @param {PluginData} pluginData - Information about the plugin. */ /** * Emitted when a plugin is removed. * @event RoomController#plugin-removed * @param {PluginData} pluginData - Information about the plugin. */ /** * Emitted when a plugin is enabled. * @event RoomController#plugin-enabled * @param {PluginData} pluginData - Information about the plugin. */ /** * Emitted when a plugin is disabled. * @event RoomController#plugin-disabled * @param {PluginData} pluginData - Information about the plugin. */ /** * RoomController provides an interface to communicate with * [HaxBall roomObject]{@link https://github.com/haxball/haxball-issues/wiki/Headless-Host#roomconfigobject} * and * [Haxball Headless Manager (HHM)]{@link https://github.com/saviola777/haxball-headless-manager}. * * Each RoomController controls one tab in the headless browser. * * You can create new RoomController instances with the * [Haxroomie#addRoom]{@link Haxroomie#addRoom} factory method. * * The API provides a Promise ready way to call the methods or optionally * you can listen to the events each method fires. */ class RoomController extends EventEmitter { /** * Constructs a new RoomController object. * * @param {object} options - Options. * @param {object} options.id - ID for the room. * @param {object} options.page - Puppeteer.Page object to control. * @param {number} [options.timeout=30] - Max time to wait in seconds for the * room to open. * @param {string} [options.hhmVersion] - Version of Haxball Headless * Manager to use. * @param {File} [hhm] - Haxball Headless Manager source. */ constructor(options) { super(); this.validateArguments(options); this.id = options.id; this.page = options.page; this.timeout = options.timeout || 30; this._hhmVersion = options.hhmVersion; this._defaultRepoVersion = options.defaultRepoVersion; this._hhm = options.hhm; this._usable = true; this._hhmLoaded = false; this._roomInfo = null; this._openRoomLock = false; this.roomOpener = new RoomOpener({ id: this.id, page: this.page, onBrowserAction: (data) => this.onBrowserAction(data), timeout: this.timeout, }); this._repositories = new RepositoryController({ page: this.page, defaultRepoVersion: this._defaultRepoVersion, }); this._plugins = new PluginController({ page: this.page }); this._roles = new RoleController({ page: this.page, plugins: this._plugins, }); this._errorHandler = new RoomErrorHandler({ page: this.page, setRoomState: this.setRoomState.bind(this), emit: this.emit.bind(this), roomId: this.id, }); this._consoleHandler = new RoomConsoleHandler({ page: this.page, emit: this.emit.bind(this), roomId: this.id, }); this.page.on('close', () => { this.emit(`page-closed`, this); this._usable = false; }); } get [Symbol.toStringTag]() { return 'RoomController'; } /** * Is the room running. * @type boolean * @default false */ get running() { return this._roomInfo ? true : false; } /** * Is Haxball Headless Manager loaded. * @type boolean * @default false */ get hhmLoaded() { return this._hhmLoaded; } /** * Is the instance still usable. * @type boolean * @default true */ get usable() { return this._usable; } /** * If room is running, contains its data (like e.g. `roomInfo.roomLink`). * If not running, then this is `null`. Returns a copy of the original * object. * @type object * @default null */ get roomInfo() { return JSON.parse(JSON.stringify(this._roomInfo)); } /** * If opening of the room is in process, then this will be `true`. * @type boolean * @default false */ get openRoomLock() { return this._openRoomLock; } /** * Object that can be used to control and get information about plugins. * * **Requires the room to be running!** * * @type PluginController */ get plugins() { if (!this.usable) throw new UnusableError(); if (!this.running) throw new RoomNotRunningError(); return this._plugins; } /** * Object that can be used to control and get information about repositories. * * **Requires the HHM library to be loaded!** * * To load HHM you can use the [init()]{@link RoomController#init} method or * open the room with [openRoom()]{@link RoomController#openRoom}. * * @type RepositoryController */ get repositories() { if (!this.usable) throw new UnusableError(); if (!this._hhmLoaded) throw new HHMNotLoadedError(); return this._repositories; } /** * Object that can be used to control and get information about roles. * * **Requires the room to be running and sav/roles plugin to be loaded * and enabled!** * * @type RoleController */ get roles() { if (!this.usable) throw new UnusableError(); if (!this.running) throw new RoomNotRunningError(); return this._roles; } /** * Validates the arguments for the constructor. * * @param {object} options - argument object for the constructor * @private */ validateArguments(options) { if (!options) { throw new Error('Missing required argument: options'); } if (!options.id && options.id !== 0) { throw new Error('Missing required argument: options.id'); } if (!options.page) throw new Error('Missing required argument: options.page'); } /** * Sets a property in this RoomController. * * Passing this to the composite objects allow them to modify the state * of the RoomController. * @param {string} property - Property to set. * @param {any} value - Value for the property. * @private */ setRoomState(property, value) { this[property] = value; } /** * This function gets called when the browser wants to send data to the * main context. * * @param {BrowserAction} action - Event arguments. * @emits RoomController#room-event * @emits RoomController# * @private */ async onBrowserAction(action) { switch (action.type) { case 'HHM_EVENT': this.handleHhmEvent; break; case 'ROOM_EVENT': this.emit('room-event', action.payload); break; } } /** * Handles the HHM_EVENT action type sent from browser context. * * @emits RoomController#plugin-loaded * @emits RoomController#plugin-removed * @emits RoomController#plugin-enabled * @emits RoomController#plugin-disabled * @param {BrowserAction} action - Data sent from browser. * @private */ handleHhmEvent(action) { switch (action.payload.eventType) { case `pluginLoaded`: this.emit('plugin-loaded', action.payload.pluginData); break; case `pluginRemoved`: this.emit('plugin-removed', action.payload.pluginData); break; case `pluginEnabled`: this.emit('plugin-enabled', action.payload.pluginData); break; case `pluginDisabled`: this.emit('plugin-disabled', action.payload.pluginData); break; } } /** * Initializes the RoomController by navigating the page to the headless * HaxBall URL and loads the Haxball Headless Manager library. * * This enables the use of the [repositories]{@link RoomController#repositories} * object to get information about repositories before opening the room. * * **Note that calling [close]{@link RoomController#close} will undo this.** * * @param {object} [options] - Options. * @param {string} [options.hhmVersion] - Version of Haxball Headless * Manager to load. By default a compatible version is used. * @param {File} [options.hhm] - Optionally load HHM source from a string. */ async init(options = {}) { if (!this.usable) throw new UnusableError('Instance unusable!'); const hhmVersion = options.hhmVersion || this._hhmVersion; const hhm = options.hhm || this._hhm; try { await this.roomOpener.initializePage({ hhmVersion, hhm, }); } catch (err) { this._hhmLoaded = false; throw err; } this._hhmLoaded = true; } /** * Opens a HaxBall room in a browser tab. * * On top of the documentated properties here, the config object can contain * any properties you want to use in your own HHM config file. * * The config object is * usable globally from within the HHM config as the `hrConfig` object. * * @param {object} config - Config object that contains the room information. * @param {string} config.token - Token to start the room with. * Obtain one from <https://www.haxball.com/headlesstoken>. * @param {string} [config.roomName] - Room name. * @param {string} [config.playerName] - Host player name. * @param {int} [config.maxPlayers] - Max players. * @param {boolean} [config.public] - Should the room be public? * @param {object} [config.geo] - Geolocation override for the room. * @param {Array.<Repository>} [config.repositories] - Array of * HHM plugin repositories. * * e.g. To load a repository from GitHub: * ```js * repositories: [ * { * type: 'github', * repository: 'morko/hhm-sala-plugins', * path: 'src', // optional (defaults to src) * version: 'master', // optional (defaults to master) * suffix: '.js', // optional (defaults to .js) * } * ], * ``` * * See {@link Repository} for the types of repositories you can use. * * @param {object} [config.pluginConfig] - Haxball Headless Manager * plugin config object. Passed to `HHM.config.plugins`. * * See [Haxball Headless Manager](https://github.com/saviola777/haxball-headless-manager) * This tells HHM which plugins to load from the available repositories. * You can also give the initial config to plugins here. * @param {File} [config.roomScript] - Regular haxball * headless script to load when starting the room. * * Disables the non essential default plugins. * @param {File} [config.hhmConfig] - Configuration for the haxball * headless manager (HHM). * @param {string} [config.defaultRepoVersion] - Version of saviola's * plugin repository for Haxball Headless Manager to load. By default * a compatible version is used. This can be overriden by adding the * repository in the `repository` property. * @returns {object} - Config that the room was started with. * The `roomLink` property is added to the config (contains URL to the * room). * * @emits RoomController#open-room-start * @emits RoomController#open-room-stop * * @throws {TypeError} - Something is wrong with the arguments. * @throws {UnusableError} - The instance is not usable because the browser * page crashed or closed. * @throws {RoomIsRunningError} - The room is already running. * @throws {RoomLockedError} - The room is already being opened. * @throws {ConnectionError} - Could not connect to HaxBall headless page. * @throws {TimeoutError} - Haxball Headless Manager took too much time to * start. * @throws {InvalidTokenError} - The token is invalid or expired. */ async openRoom(config) { logger.debug(`RoomController#openRoom: ${this.id}`); if (!this.usable) { let err = new UnusableError('Instance unusable!'); this.emit(`open-room-start`, err); throw err; } if (this.running) { let err = new RoomIsRunningError( 'The room is already running. Close it before opening again!' ); this.emit(`open-room-start`, err); throw err; } if (this._openRoomLock) { let err = new RoomLockedError('Room is already being opened!'); this.emit(`open-room-start`, err); throw err; } this.emit(`open-room-start`, null, config); this._openRoomLock = true; config.defaultRepoVersion = config.defaultRepoVersion || this._defaultRepoVersion; try { if (!this.hhmLoaded) await this.init(); this._roomInfo = await this.roomOpener.open(config); } catch (err) { this._openRoomLock = false; if (process.env.NODE_ENV !== 'development') await this.closeRoom(); this.emit(`open-room-stop`, err); throw err; } this._openRoomLock = false; this.emit(`open-room-stop`, null, this.roomInfo); return this._roomInfo; } /** * Closes the headless HaxBall room by navigating the page out of the * headless HaxBall URL. * * @emits RoomController#close-room * * @throws {UnusableError} - The instance is not usable because the browser * page crashed or closed. */ async closeRoom() { logger.debug(`RoomController#closeRoom`); if (!this.usable) { let err = new UnusableError('Instance unusable!'); this.emit(`close-room-start`, err); throw err; } this.emit(`close-room-start`); try { await this.roomOpener.close(); } catch (err) { this._usable = false; this._hhmLoaded = false; this._roomInfo = null; this.emit(`close-room-stop`, new UnusableError(err.msg)); throw new UnusableError(err.msg); } this._hhmLoaded = false; this._roomInfo = null; this.emit(`close-room-stop`); } /** * Calls a function of the * [HaxBall roomObject](https://github.com/haxball/haxball-issues/wiki/Headless-Host#roomobject) * in the browsers context. * * @param {string} fn - Name of the haxball roomObject function. * @param {any} ...args - Arguments for the function. * @returns {Promise.<any>} - Return value of the called function. * * @throws {UnusableError} - The instance is not usable because the browser * page crashed or closed. * @throws {RoomNotRunningError} - The room is not running. */ async callRoom(fn, ...args) { logger.debug( `[${this.id}] RoomController#callRoom: ${stringify(fn)}, ${stringify( args )}` ); if (!this.usable) { throw new UnusableError('Instance unusable!'); } if (!this.running) { throw new RoomNotRunningError('Room is not running.'); } if (!fn) { throw new TypeError('Missing required argument: fn'); } let result = await this.page.evaluate( (fn, args) => { return window.haxroomie.callRoom(fn, ...args); }, fn, args ); if (result.error) { throw new Error(result.payload); } return result.payload.result; } /** * Wrapper for Puppeteers * [page.evaluate](https://github.com/GoogleChrome/puppeteer/blob/v1.18.0/docs/api.md#pageevaluatepagefunction-args). * * Evaluates the given code in the browser tab this instace is controlling. * You can access the HaxBall roomObject with `HHM.manager.room`. * * e.g. * ```js * room.eval('HHM.manager.room.getPlayerList()'); * ``` * * @param {string|function} pageFunction - JavaScript to evaluate. * @param {...Serializable|...JSHandle} [args] - Arguments to pass to `js`. * @returns {Promise.<Serializable>} - Promise which resolves to the * return value of pageFunction. */ async eval(pageFunction, ...args) { return this.page.evaluate(pageFunction, ...args); } } module.exports = RoomController;