From 899a39a39307cb0dfbd7db1d5b163ba8a32b01de Mon Sep 17 00:00:00 2001 From: RattleSN4K3 Date: Sun, 13 Oct 2024 16:21:19 +0200 Subject: [PATCH] feat: add BROKE PROTOCOL support (#651) * Add support for BROKE PROTOCOL, querying master server Plus support for master query through separate protocol * Define json response via schema, optional data validation with Ajv (commented out) * Fallback query from game data server listing (servers.json) * docs: update CHANGELOG and GAMES_LIST for Broke Protocol --- CHANGELOG.md | 1 + GAMES_LIST.md | 9 + lib/games.js | 10 + protocols/brokeprotocol.js | 13 + protocols/brokeprotocolmaster.js | 419 +++++++++++++++++++++++++++++++ protocols/index.js | 4 +- 6 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 protocols/brokeprotocol.js create mode 100644 protocols/brokeprotocolmaster.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2832f65..e50e0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Feat: TOXIKK (2016) - Added support (#641) * Feat: Renegade X (2014) - Added support (#643) * Feat: Hawakening (2024) - Added support (#648) +* Feat: BROKE PROTOCOL (2024) - Added support (#651) ## 5.1.3 * Fix: `Deus Ex` using the wrong protocol (#621) diff --git a/GAMES_LIST.md b/GAMES_LIST.md index 68675bd..549aa56 100644 --- a/GAMES_LIST.md +++ b/GAMES_LIST.md @@ -53,6 +53,7 @@ | breach | Breach | [Valve Protocol](#valve) | | breed | Breed | | | brink | Brink | [Valve Protocol](#valve) | +| brokeprotocol | BROKE PROTOCOL | [Notes](#brokeprotocol) | | c2d | CS2D | | | c3db | Commandos 3: Destination Berlin | | | cacr | Command and Conquer: Renegade | | @@ -497,6 +498,14 @@ And one of the following options for gaining access: > **_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. +### BROKE PROTOCOL +When querying a server on [BROKE PROTOCOL](https://brokeprotocol.com/), you have two options for querying the server: +1. Passing the `address` and `port` request fields (or `--address` and `--port` when using the cli to the server). +2. Setting the `serverId` request field or `--serverId` when using the cli to the publicId of the server. + +You can acquire a _ServerId_ from the master query protocol `protocol-brokeprotocolmaster` (see `raw.servers[].raw.id`). + + Protocols with Additional Notes --- diff --git a/lib/games.js b/lib/games.js index b9ae6b1..0155fd0 100644 --- a/lib/games.js +++ b/lib/games.js @@ -452,6 +452,16 @@ export const games = { protocol: 'valve' } }, + brokeprotocol: { + name: 'BROKE PROTOCOL', + release_year: 2024, + options: { + protocol: 'brokeprotocol' + }, + extra: { + doc_notes: 'brokeprotocol' + } + }, basedefense: { name: 'Base Defense', release_year: 2017, diff --git a/protocols/brokeprotocol.js b/protocols/brokeprotocol.js new file mode 100644 index 0000000..c523d2f --- /dev/null +++ b/protocols/brokeprotocol.js @@ -0,0 +1,13 @@ +import brokeprotocolmaster from './brokeprotocolmaster.js' + +/** + * Implements the protocol for BROKE PROTOCOL, a Unity based game + * using a custom master server + */ +export default class brokeprotocol extends brokeprotocolmaster { + constructor () { + super() + this.doQuerySingle = true + this.requireToken = true + } +} diff --git a/protocols/brokeprotocolmaster.js b/protocols/brokeprotocolmaster.js new file mode 100644 index 0000000..d9b0298 --- /dev/null +++ b/protocols/brokeprotocolmaster.js @@ -0,0 +1,419 @@ +import Core from './core.js' +import got from 'got' +// import Ajv from 'ajv' +// const ajv = new Ajv() + +function objectKeysToLowerCase (input) { + if (typeof input !== 'object') return input + if (Array.isArray(input)) return input.map(objectKeysToLowerCase) + return Object.keys(input).reduce(function (newObj, key) { + const val = input[key] + const newVal = (typeof val === 'object') && val !== null ? objectKeysToLowerCase(val) : val + newObj[key.toLowerCase()] = newVal + return newObj + }, {}) +} + +/** + * Implements the protocol for retrieving a master list for BROKE PROTOCOL, a Unity based game + * using a custom master server + */ +export default class brokeprotocolmaster extends Core { + constructor () { + super() + + this.backendApiUriServers = 'https://bp.userr00t.com/serverbrowser/api/server' + this.backendApiUriServer = 'https://bp.userr00t.com/serverbrowser/api/server/{id}' + this.backendApiUriCheck = 'https://bp.userr00t.com/serverbrowser/api/' + this.fallbackUri = 'https://brokeprotocol.com/servers.json' + + this.hexCharacters = [ + '&0', '&1', '&2', '&3', '&4', '&5', '&6', '&7', + '&8', '&9', '&a', '&b', '&c', '&d', '&e', '&f' + ] + + // Don't use the tcp ping probing + this.usedTcp = true + } + + async run (state) { + this.hasApi = await this.checkApi() + await this.queryInfo(state) + } + + async queryInfo (state) { + if (this.doQuerySingle) { + if (this.options.serverId) { + await this.queryServerInfo(state, this.options.serverId) + } else { + await this.queryServerInfoFromMaster(state) + } + } else { + await this.queryMasterServerList(state) + } + } + + async queryMasterServerList (state) { + const servers = await this.getMasterServerList() + + // pass processed servers as raw list + state.raw.servers = servers.map((serverInfo) => { + // 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, serverInfo) + return serverState + }) + } + + async queryServerInfoFromMaster (state) { + // query master list and find specific server + const servers = await this.getMasterServerList() + const serverInfo = servers.find((server) => { + return server.ip === this.options.address && `${server.port}` === `${this.options.port}` + }) + + if (serverInfo == null) { + throw new Error('Server not found in master server list') + } + + // set state properties based on received server info + this.populateProperties(state, serverInfo) + } + + async queryServerInfo (state, serverId) { + let serverInfo = null + if (this.hasApi) { + // query server info from API + serverInfo = await this.getServerInfo(serverId) + } + + if (serverInfo == null) { + throw new Error(`Unable to retrieve server info with given id: ${serverId}`) + } + + // set state properties based on received server info + this.populateProperties(state, serverInfo) + } + + /** + * Translates raw properties into known properties + * @param {Object} state Parsed data + * @param {Object} serverInfo Queried server info + */ + populateProperties (state, serverInfo) { + state.gameHost = serverInfo.ip || null + state.gamePort = serverInfo.port || null + + state.name = this.sanitizeServerName(serverInfo.name || '') + state.map = serverInfo.map?.name || '' + state.password = false + + const snaps = [...(serverInfo.snapshots || [])] + snaps.sort((a, b) => b?.at - a?.at) + // API data only provides snapshot data, where as JSON data has "PlayerCount", try to use PlayerCount first + state.numplayers = serverInfo.playercount || snaps[0]?.playercount || 0 + state.maxplayers = serverInfo.playerlimit || 0 + + state.raw = serverInfo + state.version = serverInfo.version || '' + } + + /** + * Checks if the API is available + * @returns a list of servers as raw data + */ + async checkApi () { + try { + const response = await got(this.backendApiUriCheck, { + method: 'HEAD', + timeout: { request: 2000 }, + retry: { limit: 0 } + }) + return !!response?.ok + } catch (err) { + // ignore error message + } + + return false + } + + /** + * Retrieves server list from master server + * @throws {Error} Will throw error when no master list was received + * @returns a list of servers as raw data + */ + async getMasterServerList () { + const queryUrl = this.hasApi ? this.backendApiUriServers : this.fallbackUri + const masterData = await this.request({ + url: queryUrl, + responseType: 'json', + ...(this.hasApi ? this.getSearchParams() : {}) + }) + + // non-api data will provide server-data only + const servers = this.hasApi ? masterData.servers : masterData + if (servers == null) { + throw new Error('Unable to retrieve master server list') + } + 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 isDataValid2 = ajv.validate(SchemaDefMasterServerListArrayServers, servers) + // if (!isDataValid2) { + // throw new Error(`Received master server data is unknown/invalid: ${ajv.errorsText(ajv.errors)}`) + // } + + // API and non-API data mismatches in letter case, force lower case (Note: loosing camel-case style for API data) + const serversLowerCase = servers.map(x => objectKeysToLowerCase(x)) + return serversLowerCase + } + + /** + * Retrieves server info from API + * @param {string} serverId the server id + * @throws {Error} Will throw error when no master list was received + * @returns a list of servers as raw data + */ + async getServerInfo (serverId) { + const serverInfo = await this.request({ + url: this.backendApiUriServer.replace(/{id}/g, serverId || ''), + responseType: 'json', + ...(this.getSearchParams()) + }) + + if (serverInfo && !(typeof serverInfo === 'object' && !Array.isArray(serverInfo))) { + throw new Error('Invalid data received from API. Expecting object for server info') + } + + // TODO: Ajv response validation + // const isDataValid = ajv.validate(SchemaResponseServerInfo, serverInfo) + // if (!isDataValid) { + // throw new Error(`Received server info data is unknown/invalid: ${ajv.errorsText(ajv.errors)}`) + // } + + return serverInfo + } + + sanitizeServerName (name) { + const removeStringsRecursively = (inputString, stringsToRemove) => + stringsToRemove.reduce((str, rem) => str.replace(new RegExp(rem, 'g'), ''), inputString).trim() + + const sanitizedName = removeStringsRecursively(name, this.hexCharacters) + return sanitizedName + } + + getSearchParams () { + let intervalOption = null + const intervalOptions = ['1h', '6h', '12h', '1d', '3d', '1w', '2w', '4w'] + if (this.options.snapshotInterval) { + const opt = `${this.options.snapshotInterval}`.toLowerCase() + if (intervalOptions.includes(opt)) { + intervalOption = opt + } + } + + return { + searchParams: { + snapshotInterval: intervalOption || '1h' + } + } + } +} + +export const SchemaDefMasterServerListServer = { + type: 'object', + required: [ + 'name', + 'version', + 'ip', + 'port', + 'whitelist', + 'location', + 'validation', + 'url', + 'playerLimit', + 'difficulty', + 'map', + 'assetBundles', + 'plugins', + 'flags', + 'hourlyAverageSnapshots', + 'snapshots', + 'daySnapshots', + 'id', + 'updated', + 'created' + ], + properties: { + name: { + type: 'string' + }, + version: { + type: 'string', + enum: [ + '1.42', + '1.40', + '1.41' + ] + }, + ip: { + type: 'string', + format: 'ipv4' + }, + port: { + type: 'integer', + minimum: 0, + maximum: 65535 + }, + whitelist: { + type: 'boolean' + }, + location: { + type: 'string' + // enum: ['FR', 'GB', 'DE', 'SG', 'PL', 'NL', 'AU', 'US', 'RU'] + }, + validation: { + type: 'string' + // enum: ['+', '!'] + }, + url: { + type: 'string' + }, + playerLimit: { + type: 'integer', + minimum: 0 + }, + difficulty: { + type: [ + 'number', + 'integer' + ] + }, + map: { + type: 'object', + properties: { + hash: { type: 'string' }, + name: { type: 'string' }, + filesize: { type: 'integer' } + }, + required: ['hash', 'name', 'filesize'] + }, + assetBundles: { + type: 'array', + items: { + type: 'object', + properties: { + hash: { type: 'string' }, + name: { type: 'string' }, + filesize: { type: 'integer' } + }, + required: ['hash', 'name', 'filesize'] + } + }, + plugins: { + type: 'array', + items: { + type: 'object', + properties: { + hash: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' } + }, + required: ['hash', 'name', 'description'] + } + }, + flags: { + type: 'integer' + }, + hourlyAverageSnapshots: { + type: 'array', + items: { + items: {} + } + }, + snapshots: { + type: 'array', + items: { + type: 'object', + properties: { + playerCount: { type: 'integer' }, + at: { type: 'integer' } + }, + required: ['playerCount', 'at'] + } + }, + daySnapshots: { + type: 'array', + items: { + items: {} + } + }, + id: { + type: 'string' + }, + updated: { + type: 'string' + }, + created: { + type: 'string' + } + } +} + +export const SchemaDefMasterServerListGlobalSnapshot = { + type: 'object', + properties: { + playerCount: { type: 'integer', minimum: 0 }, + at: { type: 'integer' } + }, + required: ['playerCount', 'at'] +} + +export const SchemaDefMasterServerListGlobalDaySnapshot = { + type: 'object', + properties: { + playerCount: { type: 'integer', minimum: 0 }, + at: { type: 'integer' } + }, + required: ['playerCount', 'at'] +} + +export const SchemaDefMasterServerListArrayServers = { + type: 'array', + items: SchemaDefMasterServerListServer +} + +export const SchemaResponseServerInfo = SchemaDefMasterServerListServer + +export const SchemaResponseMasterServerList = { + type: 'object', + required: [ + 'servers', + 'globalSnapshots', + 'globalDaySnapshots' + ], + properties: { + servers: { $ref: '#/$defs/servers' }, + globalSnapshots: { + type: 'array', + items: { $ref: '#/$defs/globalSnapshot' } + }, + globalDaySnapshots: { + type: 'array', + items: { $ref: '#/$defs/globalDaySnapshot' } + } + }, + $defs: { + servers: SchemaDefMasterServerListArrayServers, + globalSnapshot: SchemaDefMasterServerListGlobalSnapshot, + globalDaySnapshot: SchemaDefMasterServerListGlobalDaySnapshot + } +} diff --git a/protocols/index.js b/protocols/index.js index 692c3c9..b4ec99d 100644 --- a/protocols/index.js +++ b/protocols/index.js @@ -3,6 +3,8 @@ import ase from './ase.js' import asa from './asa.js' import assettocorsa from './assettocorsa.js' import battlefield from './battlefield.js' +import brokeprotocol from './brokeprotocol.js' +import brokeprotocolmaster from './brokeprotocolmaster.js' import buildandshoot from './buildandshoot.js' import cs2d from './cs2d.js' import discord from './discord.js' @@ -71,7 +73,7 @@ import vintagestorymaster from './vintagestorymaster.js' import vintagestory from './vintagestory.js' export { - armagetron, ase, asa, assettocorsa, battlefield, buildandshoot, cs2d, discord, doom3, eco, epic, factorio, farmingsimulator, ffow, + armagetron, ase, asa, assettocorsa, battlefield, brokeprotocol, brokeprotocolmaster, buildandshoot, cs2d, discord, doom3, eco, epic, factorio, farmingsimulator, ffow, 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,