UNPKG

gamedig

Version:

Query for the status of any game server in Node.JS

719 lines (634 loc) 23.5 kB
import Core from './core.js' // import Ajv from 'ajv' // const ajv = new Ajv() /** * Implements the protocol for retrieving a master list for Hawakening, a fan project of the UnrealEngine3 based game HAWKEN * using a Meteor backend for the master server */ export default class hawakeningmaster extends Core { constructor () { super() // backend API url for original Hawken release // const meteorUri = 'https://v2-services-live-pc.playhawken.com' // Hawakening API for public release in 2024 const meteorUri = 'https://hawakening.com/api' this.backendApi = new MeteorBackendApi(this, meteorUri) this.backendApi.setLogger(this.logger) // set when querying needs access token this.requireToken = false // set when querying for specific server only this.doQuerySingle = false // set to logout on cleanup (to revoke access token) this.doLogout = true // stored user, queried from backend this.userInfo = null // Don't use the tcp ping probing this.usedTcp = true } async run (state) { await this.retrieveClientAccessToken() await this.retrieveUser() await this.queryInfo(state) await this.cleanup(state) } async queryInfo (state) { if (this.doQuerySingle) { await this.queryInfoSingle(state) } else { await this.queryInfoMultiple(state) } } async queryInfoMultiple (state) { const servers = await this.getMasterServerList() // pass processed servers as raw list state.raw.servers = servers.map((serverListing) => { // TODO: may use any other deep-copy method like structuredClone() (in Node.js 17+) // or use a method of Core to retrieve a clean state const serverState = JSON.parse(JSON.stringify(state)) // set state properties based on received server info this.populateProperties(serverState, { serverListing }) return serverState }) } async queryInfoSingle (state) { const servers = await this.getMasterServerList() const serverListing = servers.find((server) => { return server.Guid === this.options.serverId }) this.logger.debug('Server Listing:', serverListing) if (serverListing == null) { throw new Error('Server not found in master server listing') } const serverInfo = await this.getServerInfo(serverListing) this.logger.debug('Server Info:', serverInfo) if (!serverInfo) { throw new Error('Invalid server info received') } // set state properties based on received server info this.populateProperties(state, { serverListing, serverInfo }) } async cleanup (state) { await this.sendExitMessage() await this.sendLogout() this.backendApi.cleanup() this.userInfo = null } /** * Translates raw properties into known properties * @param {Object} state Parsed data * @param {Object} data Queried data */ populateProperties (state, data) { const { serverListing: listing, serverInfo: info } = data if (info) { state.gameHost = info.AssignedServerIp || null state.gamePort = info.AssignedServerPort || null } state.name = listing.ServerName || '' state.map = listing.Map || '' state.password = !!listing.DeveloperData?.PasswordHash state.numplayers = listing.Users?.length || 0 state.maxplayers = listing.MaxUsers || 0 state.version = listing.GameVersion || '' // provide raw server info Object.assign(state.raw, { listing, info }) } async retrieveClientAccessToken () { if (this.options.token) { this.doLogout = false this.backendApi.accessToken = this.options.token await this.checkAccess() return } if (!this.options.username && !this.requireToken) { this.logger.debug('retrieveClientAccessToken: No username provided but no token required for current protocol.') return } this.logger.debug(`Retrieving user access token for ${this.options.username}...`) const response = await this.backendApi.getClientAccessToken(this.options.username, this.options.password) const tag = 'access token' MeteorBackendApi.AssertResponse(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['Access Grant Not Issued: Unrecognized options for login request'], errorMessage: 'No user name or password' }) MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['Access Grant Not Issued: User not found'], errorMessage: 'Invalid user name' }) MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['Access Grant Not Issued: Incorrect password'], errorMessage: 'Incorrect password' }) MeteorBackendApi.AssertResponseStatus(response, tag, { printStatus: true }) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['User Logged In'] }) MeteorBackendApi.AssertResponseData(response, tag) this.backendApi.accessToken = response.Result await this.checkAccess() } async retrieveUser () { if (!this.options.username && !this.requireToken) { this.logger.debug('retrieveUser: No username provided but no token required for current protocol.') return } this.userInfo = await this.getUserInfo() } async checkAccess () { this.logger.debug('Checking access ...') const responseServices = await this.backendApi.getStatusServices() MeteorBackendApi.AssertResponseStatus(responseServices, 'service status') MeteorBackendApi.AssertResponseMessage(responseServices, 'service status', { expected: ['Status found'] }) const responseTest = await this.backendApi.getBundles() MeteorBackendApi.AssertResponseStatus(responseTest, 'bundles') MeteorBackendApi.AssertResponseMessage(responseTest, 'bundles', { expected: ['Bundles Filter successful'] }) } async getUserInfo () { this.logger.debug(`Requesting user info for ${this.options.username} ...`) const response = await this.backendApi.getUserInfo(this.options.username) const tag = 'user info' MeteorBackendApi.AssertResponse(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['User not found'], errorMessage: 'Invalid or no user name' }) MeteorBackendApi.AssertResponseStatus(response, tag, { printStatus: true }) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Userfound'] }) MeteorBackendApi.AssertResponseData(response, tag) return response.Result } async getMasterServerList () { this.logger.debug('Requesting game servers ...') const response = await this.backendApi.getMasterServerList() const tag = 'server list' MeteorBackendApi.AssertResponseStatus(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Listings found'] }) MeteorBackendApi.AssertResponseData(response, tag) const servers = response.Result if (!Array.isArray(servers)) { throw new Error('Invalid data received from master server. Expecting list of data') } if (servers.length === 0) { throw new Error('No data received from master server.') } // TODO: Ajv response validation // const isDataValid = ajv.validate(MasterServerResponseSchema, servers) // if (!isDataValid) { // throw new Error(`Received master server data is unknown/invalid: ${ajv.errorsText(ajv.errors)}`) // } return servers } async getServerInfo (serverListing) { // match info is received by requesting a matchmaking "token" // if the server is at capacity, the response won't provide valid data (500 error) // return an empty server info when server is already full if (serverListing.MaxUsers == serverListing.Users?.length) { return {} } const serverToken = await this.getServerToken(serverListing) const matchInfo = await this.getMatchInfo(serverToken) return matchInfo } async getServerToken (serverListing) { this.logger.debug(`Requesting server token ${serverListing.Guid} ...`) const response = await this.backendApi.getServerToken(serverListing, this.userInfo) const tag = 'server token' MeteorBackendApi.AssertResponseStatus(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Succesfully created the advertisement'] }) MeteorBackendApi.AssertResponseData(response, tag) return response.Result } async getMatchInfo (serverToken) { this.logger.debug(`Requesting match info ${serverToken} ...`) const response = await this.backendApi.getMatchInfo(serverToken) const tag = 'match info' MeteorBackendApi.AssertResponseStatus(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Successfully loaded ClientMatchmakingAdvertisement.'] }) MeteorBackendApi.AssertResponseData(response, tag) return response.Result } async sendExitMessage () { // in case of non-authorized query, early out and skip sending logout message if (!this.backendApi.accessToken || !this.userInfo) { return } this.logger.debug('Sending exit notify message ...') const response = await this.backendApi.notifyExit(this.userInfo) const tag = 'exit message' MeteorBackendApi.AssertResponseStatus(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Event emission successful'] }) } async sendLogout () { // in case of no logged user or non-authorized query, early out and skip sending logout message if (!this.doLogout || !this.backendApi.accessToken || !this.userInfo) { return } this.logger.debug(`Sending logout message for ${this.userInfo?.EmailAddress || this.userInfo.Guid}...`) const response = await this.backendApi.logout(this.userInfo) const tag = 'logout message' MeteorBackendApi.AssertResponseStatus(response, tag) MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['AccessGrant Revoked'] }) } } /** * Deeply merges two objects, combining their properties recursively. * * If both objects have a property with the same key and that property is an object, * the properties of the second object will be merged into the first object's property. * If the property is not an object or if it does not exist in the first object, * the property from the second object will overwrite the property in the first object. * * @param {Object} obj1 - The first object to merge. * @param {Object} obj2 - The second object to merge. * @returns {Object} A new object containing the merged properties of both input objects. */ function deepMerge (obj1, obj2) { const result = { ...obj1 } for (const key in obj2) { if (Object.hasOwn(obj2, key)) { if (obj2[key] instanceof Object && obj1[key] instanceof Object) { result[key] = deepMerge(obj1[key], obj2[key]) } else { result[key] = obj2[key] } } } return result } function isObject (item) { return (typeof item === 'object' && !Array.isArray(item) && item !== null) } /** * Class representing a client for the Meteor Backend API. * * This class provides methods for interacting with the Meteor Backend API, including * authentication, retrieving user information, and handling server-related operations. */ export class MeteorBackendApi { #accessToken = null #protocol = null #apiUri = null /** * Creates an instance of the MeteorBackendApi. * * @param {Object} protocol - The protocol object to handle requests. * @param {string} apiUri - The base URI for the API. */ constructor (protocol, apiUri) { this.#protocol = protocol this.#apiUri = apiUri this.logger = null } /** * The base URI of the API. * * @returns {string} The API URI. */ get apiUri () { return this.#apiUri } /** * Sets the current access token * @param {string} value the access token */ set accessToken (value) { this.#accessToken = value } /** * Returns the current access token */ get accessToken () { return this.#accessToken } /** * Sets the logger for the instance. * * @param {Object} logger - The logger instance to use for logging. */ setLogger (logger) { this.logger = logger } /** * Makes an API call to the specified endpoint with the given request parameters. * * @param {string} endpoint - The API endpoint to call. * @param {Object} requestParams - The parameters for the API request. * @param {Object} callParams - Additional parameters for the call. * @param {boolean} [callParams.requireAuth=false] - Whether the call requires authentication. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ makeCall (endpoint, requestParams = null, callParams = null) { const { requireAuth = false } = callParams ?? {} const url = `${this.#apiUri}/${endpoint}` const headers = { Accept: '*/*', 'Content-Type': 'application/json', ...(requireAuth ? { Authorization: `Basic ${this.accessToken}` } : {}) } const defaultParams = { url, headers, method: 'GET', responseType: 'json' } const requestCollection = deepMerge(defaultParams, requestParams) this.logger?.debug(`${requestCollection.method || 'GET'}: ${url}`) const response = this.#protocol.request(requestCollection) return response } /** * Cleans up the instance */ cleanup () { this.accessToken = null } /** * Retrieves the status of the service. * * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getStatusServices () { const response = this.makeCall('status/services') return response } /** * Retrieves Bundles. * * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getBundles () { const response = this.makeCall('bundles', {}, { requireAuth: true }) return response } /** * Retrieves an access token for a client using the provided username and password. * * @param {string} userName - The username of the client. * @param {string} password - The password of the client. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getClientAccessToken (userName, password) { const endpoint = `users/${encodeURIComponent(userName)}/accessGrant` const body = { Password: password } const response = this.makeCall(endpoint, { json: body, method: 'POST' }) return response } /** * Retrieves user information based on the username. * * @param {string} userName - The username of the user. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getUserInfo (userName) { const endpoint = `users/${encodeURIComponent(userName)}` const response = this.makeCall(endpoint, {}, { requireAuth: true }) return response } /** * Retrieves a list of master servers. * * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getMasterServerList () { const response = this.makeCall('gameServerListings', {}, { requireAuth: true }) return response } /** * Retrieves a server token based on the server listing and user information. * * @param {Object} serverListing - The server listing object containing server details. * @param {Object} userInfo - The user information object. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getServerToken (serverListing, userInfo) { const body = { GameVersion: serverListing.GameVersion, OwnerGuid: userInfo.Guid, Region: serverListing.Region, RequestedServerGuid: serverListing.Guid, Users: [userInfo.Guid] } const response = this.makeCall('hawkenClientMatchmakingAdvertisements', { json: body, method: 'POST' }, { requireAuth: true }) return response } /** * Retrieves match information based on the server token. * * @param {string} serverToken - The token of the server. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ getMatchInfo (serverToken) { const endpoint = `hawkenClientMatchmakingAdvertisements/${serverToken}` const response = this.makeCall(endpoint, {}, { requireAuth: true }) return response } /** * Notifies the system that a user has exited. * * @param {Object} userInfo - The user information object. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ notifyExit (userInfo) { const body = [{ Data: { TimeCreated: (new Date().getTime() / 1000) }, Producer: { Id: '\\Hawken-CL142579\\Binaries\\Win32\\HawkenGame-Win32-Shipping.exe', Type: 'HawkenGameClient' }, Subject: { Id: userInfo.Guid, Type: 'Player' }, Timestamp: (new Date().toISOString()), Verb: 'ExitClient' }] const response = this.makeCall('gameClientEvent', { json: body, method: 'POST' }, { requireAuth: true }) return response } /** * Logs out a user based on their information. * * @param {Object} userInfo - The user information object. * @returns {Promise<Object>} A promise that resolves to the response object from the API call. */ logout (userInfo) { const endpoint = `users/${userInfo.Guid}/accessGrant` const body = { AccessGrant: this.accessToken } const response = this.makeCall(endpoint, { json: body, method: 'PUT' }, { requireAuth: true }) return response } /** * Asserts that the response is valid. * * @static * @param {Object} response - The response object to validate. * @param {string} tag - A tag for the error message. * @param {Object} [params={}] - Additional parameters. * @param {boolean} [params.printStatus=false] - Whether to include the status in the error message. * @throws {Error} If the response is invalid. */ static AssertResponse (response, tag, params = {}) { const { printStatus = false } = (params || {}) if (!response) { const statusMessage = printStatus ? `Response Status: ${response.Status}` : '' throw new Error(`Error retrieving ${tag || 'data'} with no valid response.${statusMessage}`) } } /** * Asserts that the response status is valid. * * @static * @param {Object} response - The response object to validate. * @param {string} tag - A tag for the error message. * @param {Object} [params={}] - Additional parameters. * @param {boolean} [params.checkStatus=true] - Whether to check the status code. * @param {boolean} [params.printStatus=false] - Whether to include the status in the error message. * @throws {Error} If the response status is invalid. */ static AssertResponseStatus (response, tag, params = {}) { const { checkStatus = true, printStatus = false } = (params || {}) if (!response || !checkStatus || response.Status !== 200) { const statusMessage = printStatus ? `Response Status: ${response.Status}` : '' throw new Error(`Error retrieving ${tag || 'data'} with no valid response.${statusMessage}`) } } /** * Asserts that the response message is valid. * * @static * @param {Object} response - The response object to validate. * @param {string} tag - A tag for the error message. * @param {Object} [params={}] - Additional parameters. * @param {Array<string>} [params.expected=[]] - Expected messages. * @param {Array<string>} [params.match=[]] - Matching messages. * @param {boolean} [params.printCurrent=true] - Whether to include the current message in the error message. * @throws {Error} If the response message is invalid. */ static AssertResponseMessage (response, tag, params = {}) { const { expected = [], match = [], errorMessage, printCurrent = true } = (params || {}) const responseMessage = response?.Message?.toLowerCase() if (expected?.length && !expected.some(x => responseMessage === `${x}`.toLowerCase())) { const currentMessage = printCurrent ? ` Response message: ${response.Message}` : '' throw new Error(`Invalid ${tag || 'data'} message received.${currentMessage}`) } if (match?.some(x => responseMessage === `${x}`.toLowerCase())) { throw new Error(errorMessage || `Invalid ${tag || 'data'} message received.`) } } /** * Asserts that the response contains valid data. * * @static * @param {Object} response - The response object to validate. * @param {string} tag - A tag for the error message. * @param {string} [key='Result'] - The key to check in the response. * @throws {Error} If the response does not contain valid data. */ static AssertResponseData (response, tag, key = 'Result') { if (response && (!isObject(response) || !response[key])) { throw new Error(`No ${tag || 'data'} received`) } } } export const MasterServerServerListingSchema = { type: 'object', required: [ 'userGuid', 'AllowedRoles', 'DeveloperData', 'Endpoint', 'GameType', 'GameVersion', 'IsMatchmakingVisible', 'IsPublicVisible', 'LastUpdate', 'Map', 'MatchCompletionPercent', 'MatchId', 'MaxUsers', 'MinUsers', 'Port', 'Region', 'ServerName', 'ServerRanking', 'ServerScore', 'Status', 'Users', 'VoiceChannelListing', 'Guid' ], properties: { userGuid: { type: 'string' }, AllowedRoles: { type: 'array', items: { items: {} } }, DeveloperData: { type: 'object', properties: { AveragePilotLevel: { type: 'string' }, MatchState: { type: 'string' }, bIgnoreMMR: { type: 'string' }, bTournament: { type: 'string' }, PasswordHash: { type: 'string' } }, required: [ 'AveragePilotLevel', 'MatchState', 'bIgnoreMMR', 'bTournament' ] }, Endpoint: { type: 'null' }, GameType: { type: 'string' }, GameVersion: { type: 'string' }, IsMatchmakingVisible: { type: 'boolean' }, IsPublicVisible: { type: 'boolean' }, LastUpdate: { type: 'string' }, Map: { type: 'string' }, MatchCompletionPercent: { type: 'integer', minimum: 0 }, MatchId: { type: 'string', pattern: '^[A-Fa-f0-9]{32}$' }, MaxUsers: { type: 'integer', minimum: 0 }, MinUsers: { type: 'integer', minimum: 0 }, Port: { type: 'null' }, Region: { type: 'string', enum: [ 'Asia', 'Europe', 'North-America', 'Oceania' ] }, ServerName: { type: 'string' }, ServerRanking: { type: 'integer' }, ServerScore: { type: 'string' }, Status: { type: 'integer' }, Users: { type: 'array', items: { type: 'string', format: 'uuid' } }, VoiceChannelListing: { type: 'string' }, Guid: { type: 'string', format: 'uuid' } } } export const MasterServerResponseSchema = { type: 'array', items: { $ref: '#/$defs/server' }, $defs: { server: MasterServerServerListingSchema } }