mirror of
https://github.com/tribufu/node-gamedig
synced 2026-05-06 07:07:33 +00:00
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
This commit is contained in:
parent
d2397b67e7
commit
899a39a393
6 changed files with 455 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
### <a name='brokeprotocol'></a>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
|
||||
---
|
||||
|
||||
|
|
|
|||
10
lib/games.js
10
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,
|
||||
|
|
|
|||
13
protocols/brokeprotocol.js
Normal file
13
protocols/brokeprotocol.js
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
419
protocols/brokeprotocolmaster.js
Normal file
419
protocols/brokeprotocolmaster.js
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue