diff --git a/protocols/hawakening.js b/protocols/hawakening.js index 795449a2..7489c282 100644 --- a/protocols/hawakening.js +++ b/protocols/hawakening.js @@ -1,315 +1,12 @@ -import Core from './core.js' -import { MeteorBackendApi } from './hawakeningmaster.js' -// import Ajv from 'ajv' -// const ajv = new Ajv() - -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 - } -} +import hawakeningmaster from './hawakeningmaster.js' /** * Implements the protocol for Hawkening, a fan project of the UnrealEngine3 based game HAWKEN * using a Meteor backend for the master server */ -export default class hawakening extends Core { +export default class hawakening extends hawakeningmaster { constructor () { super() - - // this.meteorUri = 'https://v2-services-live-pc.playhawken.com' - const meteorUri = 'https://hawakening.com/api' - this.backendApi = new MeteorBackendApi(this, meteorUri) - this.backendApi.setLogger(this.logger) - - this.doLogout = true - 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) { - 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 - Object.assign(state.raw, { serverListing, serverInfo }) - this.populateProperties(state) - } - - 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 - */ - populateProperties (state) { - const { serverListing: listing, serverInfo: info } = state.raw - - if (info) { - state.gameHost = info.AssignedServerIp - state.gamePort = info.AssignedServerPort - } - - state.name = listing.ServerName || '' - state.map = listing.Map || '' - state.numplayers = listing.Users?.length || 0 - state.maxplayers = listing.MaxUsers || 0 - state.version = listing.GameVersion || '' - } - - async retrieveClientAccessToken () { - if (this.options.token) { - this.doLogout = false - this.backendApi.accessToken = this.options.token - await this.checkAccess() - return - } - - this.logger.debug(`Retrieving user access token for ${this.options.username}...`) - const response = await this.backendApi.getClientAccessToken(this.options.username, this.options.password) - - MeteorBackendApi.AssertResponse(response, 'access token') - MeteorBackendApi.AssertResponseMessage(response, { match: ['Access Grant Not Issued: User not found'], errorMessage: 'Invalid user name' }) - MeteorBackendApi.AssertResponseMessage(response, { match: ['Access Grant Not Issued: Incorrect password'], errorMessage: 'Incorrect password' }) - MeteorBackendApi.AssertResponseStatus(response, 'access token', { printStatus: true }) - MeteorBackendApi.AssertResponseMessage(response, 'access token', { expected: ['User Logged In'] }) - MeteorBackendApi.AssertResponseData(response, 'access token') - this.backendApi.accessToken = response.Result - } - - async retrieveUser () { - this.userInfo = await this.getUserInfo() - } - - async checkAccess () { - this.logger.debug('Checking access ...') - const response = await this.backendApi.getStatusServices() - MeteorBackendApi.AssertResponseStatus(response, 'service status') - MeteorBackendApi.AssertResponseMessage(response, 'service status', { expected: ['Status found'] }) - } - - 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.AssertResponseStatus(response, tag) - 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) { - 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 () { - 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 () { - if (!this.doLogout) { - 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'] }) + this.doQuerySingle = true } } diff --git a/protocols/hawakeningmaster.js b/protocols/hawakeningmaster.js index e4db4d02..9985a0cc 100644 --- a/protocols/hawakeningmaster.js +++ b/protocols/hawakeningmaster.js @@ -1,3 +1,8 @@ +import Core from './core.js' +// import Ajv from 'ajv' +// const ajv = new Ajv() + + /** * Deeply merges two objects, combining their properties recursively. * @@ -286,17 +291,22 @@ export class MeteorBackendApi { * @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 = [], printCurrent = true } = (params || {}) + const { expected = [], match = [], errorMessage, printCurrent = true } = (params || {}) const responseMessage = response?.Message?.toLowerCase() - if (!expected?.some(x => responseMessage === `${x}`.toLowerCase())) { - const currentMessage = printCurrent ? `Response message: ${response.Message}` : '' + 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.`) + } } /** @@ -314,3 +324,352 @@ export class MeteorBackendApi { } } } + + +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 + } +} + +/** + * Implements the protocol for retrieving a master list for Hawkening, 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() + + // this.meteorUri = 'https://v2-services-live-pc.playhawken.com' + const meteorUri = 'https://hawakening.com/api' + this.backendApi = new MeteorBackendApi(this, meteorUri) + this.backendApi.setLogger(this.logger) + + this.doQuerySingle = false + this.doLogout = true + 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 + } + + 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: 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 + } + + async retrieveUser () { + this.userInfo = await this.getUserInfo() + } + + async checkAccess () { + this.logger.debug('Checking access ...') + const response = await this.backendApi.getStatusServices() + MeteorBackendApi.AssertResponseStatus(response, 'service status') + MeteorBackendApi.AssertResponseMessage(response, 'service status', { expected: ['Status found'] }) + } + + 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.AssertResponseStatus(response, tag) + 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 () { + 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 () { + if (!this.doLogout) { + 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'] }) + } +} diff --git a/protocols/index.js b/protocols/index.js index 37aec77d..f207ec83 100644 --- a/protocols/index.js +++ b/protocols/index.js @@ -21,6 +21,7 @@ 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' @@ -69,7 +70,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, hawakening, 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, 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 956a6809..c628557f 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'] +const ignoredProtocols = ['discord', 'beammpmaster', 'beammp', 'teamspeak2', 'teamspeak3', 'vintagestorymaster', 'hawakeningmaster'] const protocolListFiltered = protocolList.filter((protocol) => !ignoredProtocols.includes(protocol)) const run = async () => {