From d2397b67e7d9b8221fab391649a5e93208ca0584 Mon Sep 17 00:00:00 2001 From: RattleSN4K3 Date: Sat, 12 Oct 2024 16:51:56 +0200 Subject: [PATCH] feat: add Hawakening support (#648) * Add support for Hawakening, querying master server * Outsource backend calls into Api class * Define json response via schema, optional data validation with Ajv (commented out) * Add support for Hawakening master query through separate protocol Protocol 'hawakeningmaster' provides full list of processed server info * docs: update CHANGELOG and GAMES_LIST for Hawakening * Additional API check + cleanup * Allowing public/non-authorized master server query for Hawkening severs * Fix: Reference the master protocol correctly in docs/games_list * Reorganized code file, moved schema and API-class to the end --- CHANGELOG.md | 1 + GAMES_LIST.md | 17 + lib/games.js | 12 + protocols/hawakening.js | 13 + protocols/hawakeningmaster.js | 718 ++++++++++++++++++++++++++++++++++ protocols/index.js | 4 +- tools/attempt_protocols.js | 2 +- 7 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 protocols/hawakening.js create mode 100644 protocols/hawakeningmaster.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f9e64..2832f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Feat: Update Soldat protocol (#642) * Feat: TOXIKK (2016) - Added support (#641) * Feat: Renegade X (2014) - Added support (#643) +* Feat: Hawakening (2024) - Added support (#648) ## 5.1.3 * Fix: `Deus Ex` using the wrong protocol (#621) diff --git a/GAMES_LIST.md b/GAMES_LIST.md index 70a9f07..68675bd 100644 --- a/GAMES_LIST.md +++ b/GAMES_LIST.md @@ -150,6 +150,7 @@ | gus | Gore: Ultimate Soldier | | | halo | Halo | | | halo2 | Halo 2 | | +| hawakening | Hawakening | [Notes](#hawakening) | | heretic2 | Heretic II | | | hexen2 | Hexen II | | | hiddendangerous2 | Hidden & Dangerous 2 | | @@ -480,6 +481,22 @@ If you know your use case, it's better to use a single protocol or make your own Farming Simulator servers need a token (reffered as code in the game). It can be obtained at your server's web interface (http://ip:port/settings.html). It can be passed to GameDig with the additional option: `token`. It does only work for your own server. The response includes much information about the server. Currently, only the fields about server information (name, map, version, etc.), players and mods are parsed. + +### Hawakening +Querying server info for Hawakening requires a _ServerId_ to be passed to GameDig instead of an IP address. You can acquire such a _ServerId_ from the master query protocol _hawakeningmaster_ [type: `protocol-hawakeningmaster`] (see `raw.servers[]raw.listing.Guid`). + +Additionally, the master server requires authorization. A **user profile is required** for querying the API. Such a profile can be created on the [_official page_](https://hawakening.com/enlist). + +- Provide a Server Id via `serverId` +- Provide a client profile with `username` (email address, not callsign) + +And one of the following options for gaining access: +- Provide a client access token via the option `token` +- Provide the user profile password via the option `password` + +> **_NOTE:_** The protocol `hawakening` will query additional server info by requesting a matchmaking _token_, which will fail for full servers. Due to this, the IP address and port cannot be queried for such servers. + + Protocols with Additional Notes --- diff --git a/lib/games.js b/lib/games.js index 3b52ca1..b9ae6b1 100644 --- a/lib/games.js +++ b/lib/games.js @@ -1457,6 +1457,18 @@ export const games = { protocol: 'gamespy2' } }, + hawakening: { + name: 'Hawakening', + release_year: 2024, + options: { + port: 7777, + port_query: 27015, + protocol: 'hawakening' + }, + extra: { + doc_notes: 'hawakening' + } + }, heretic2: { name: 'Heretic II', release_year: 1998, diff --git a/protocols/hawakening.js b/protocols/hawakening.js new file mode 100644 index 0000000..836531e --- /dev/null +++ b/protocols/hawakening.js @@ -0,0 +1,13 @@ +import hawakeningmaster from './hawakeningmaster.js' + +/** + * Implements the protocol for Hawakening, a fan project of the UnrealEngine3 based game HAWKEN + * using a Meteor backend for the master server + */ +export default class hawakening extends hawakeningmaster { + constructor () { + super() + this.doQuerySingle = true + this.requireToken = true + } +} diff --git a/protocols/hawakeningmaster.js b/protocols/hawakeningmaster.js new file mode 100644 index 0000000..f8484d1 --- /dev/null +++ b/protocols/hawakeningmaster.js @@ -0,0 +1,718 @@ +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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} [params.expected=[]] - Expected messages. + * @param {Array} [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 + } +} diff --git a/protocols/index.js b/protocols/index.js index 4aac12a..692c3c9 100644 --- a/protocols/index.js +++ b/protocols/index.js @@ -20,6 +20,8 @@ import gamespy3 from './gamespy3.js' import geneshift from './geneshift.js' import goldsrc from './goldsrc.js' import gtasao from './gtasao.js' +import hawakening from './hawakening.js' +import hawakeningmaster from './hawakeningmaster.js' import hexen2 from './hexen2.js' import jc2mp from './jc2mp.js' import kspdmp from './kspdmp.js' @@ -70,7 +72,7 @@ import vintagestory from './vintagestory.js' export { armagetron, ase, asa, assettocorsa, battlefield, buildandshoot, cs2d, discord, doom3, eco, epic, factorio, farmingsimulator, ffow, - fivem, gamespy1, gamespy2, gamespy3, geneshift, goldsrc, gtasao, hexen2, jc2mp, kspdmp, mafia2mp, mafia2online, minecraft, + fivem, gamespy1, gamespy2, gamespy3, geneshift, goldsrc, gtasao, hawakening, hawakeningmaster, hexen2, jc2mp, kspdmp, mafia2mp, mafia2online, minecraft, minecraftbedrock, minecraftvanilla, minetest, mumble, mumbleping, nadeo, openttd, palworld, quake1, quake2, quake3, renegadex, renegadexmaster, rfactor, ragemp, samp, satisfactory, soldat, savage2, starmade, starsiege, teamspeak2, teamspeak3, terraria, toxikk, tribes1, tribes1master, unreal2, ut3, valve, vcmp, ventrilo, warsow, eldewrito, beammpmaster, beammp, dayz, theisleevrima, xonotic, altvmp, vintagestorymaster, vintagestory diff --git a/tools/attempt_protocols.js b/tools/attempt_protocols.js index 0dec095..353aa19 100644 --- a/tools/attempt_protocols.js +++ b/tools/attempt_protocols.js @@ -20,7 +20,7 @@ const gamedig = new GameDig(options) const protocolList = [] Object.keys(protocols).forEach((key) => protocolList.push(key)) -const ignoredProtocols = ['discord', 'beammpmaster', 'beammp', 'teamspeak2', 'teamspeak3', 'vintagestorymaster', 'renegadexmaster'] +const ignoredProtocols = ['discord', 'beammpmaster', 'beammp', 'teamspeak2', 'teamspeak3', 'vintagestorymaster', 'renegadexmaster', 'hawakeningmaster'] const protocolListFiltered = protocolList.filter((protocol) => !ignoredProtocols.includes(protocol)) const run = async () => {