diff --git a/CHANGELOG.md b/CHANGELOG.md index e6272ec..5ba33d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ * Factorio (2016) - Added support (By @Vito0912 #527) * Farming Simulator 22 (2021) - Added support (By @Vito0912 #531) * Farming Simulator 19 (2018) - Added support (By @Vito0912 #531) +* Assetto Corsa - Fixed how `state.numplayers` is set (By @podrivo #538) +* TeamSpeak 2 - Fixed how `state.name` is set (By @podrivo #544) +* Grand Theft Auto: San Andreas OpenMP - Fixed `state.players` returning an empty array (By @Focus04 #547) +* Perf: Re-write of the `core` class. +* Perf: Remove many if statements from `GameSpy2`. ## 5.0.0-beta.2 * Fixed support for projects using `require`. diff --git a/GAMES_LIST.md b/GAMES_LIST.md index 26ee8e4..787db0f 100644 --- a/GAMES_LIST.md +++ b/GAMES_LIST.md @@ -356,6 +356,7 @@ * GTR 2 * Haze * Hexen World +* Last Oasis (#248, #446, #352) * Lost Heaven * Multi Theft Auto * Pariah diff --git a/lib/games.js b/lib/games.js index 97a3436..434638a 100644 --- a/lib/games.js +++ b/lib/games.js @@ -2446,7 +2446,7 @@ export const games = { release_year: 2019, options: { port: 7777, - protocol: 'samp' + protocol: 'gtasao' }, extra: { old_id: 'saomp' diff --git a/protocols/assettocorsa.js b/protocols/assettocorsa.js index b6ef6e4..eea13ed 100644 --- a/protocols/assettocorsa.js +++ b/protocols/assettocorsa.js @@ -36,6 +36,6 @@ export default class assettocorsa extends Core { } } - state.numplayers = carInfo.Cars.length + state.numplayers = serverInfo.clients } } diff --git a/protocols/core.js b/protocols/core.js index e717dee..033e5c2 100644 --- a/protocols/core.js +++ b/protocols/core.js @@ -31,7 +31,9 @@ export default class Core extends EventEmitter { // Runs a single attempt with a timeout and cleans up afterward async runOnceSafe () { - if (this.options.debug) { + const { debug, attemptTimeout } = this.options + + if (debug) { this.logger.debugEnabled = true } this.logger.prefix = 'Q#' + (uid++) @@ -41,16 +43,16 @@ export default class Core extends EventEmitter { this.logger.debug('Options:', this.options) let abortCall = null - this.abortedPromise = new Promise((resolve, reject) => { + this.abortedPromise = new Promise((_resolve, reject) => { abortCall = () => reject(new Error('Query is finished -- cancelling outstanding promises')) }).catch(() => { - // Make sure that if this promise isn't attached to, it doesn't throw a unhandled promise rejection + // Make sure that if this promise isn't attached to, it doesn't throw an unhandled promise rejection }) let timeout try { const promise = this.runOnce() - timeout = Promises.createTimeout(this.options.attemptTimeout, 'Attempt') + timeout = Promises.createTimeout(attemptTimeout, 'Attempt') const result = await Promise.race([promise, timeout]) this.logger.debug('Query was successful') return result @@ -58,9 +60,9 @@ export default class Core extends EventEmitter { this.logger.debug('Query failed with error', e) throw e } finally { - timeout && timeout.cancel() + timeout?.cancel() try { - abortCall() + abortCall?.() } catch (e) { this.logger.debug('Error during abort cleanup: ' + e.stack) } @@ -68,34 +70,29 @@ export default class Core extends EventEmitter { } async runOnce () { - const options = this.options + const { options, dnsResolver } = this + if (('host' in options) && !('address' in options)) { - const resolved = await this.dnsResolver.resolve(options.host, options.ipFamily, this.srvRecord) + const resolved = await dnsResolver.resolve(options.host, options.ipFamily, this.srvRecord) options.address = resolved.address - if (resolved.port) options.port = resolved.port + options.port ||= resolved.port } const state = new Results() - await this.run(state) - state.queryPort = options.port + state.queryPort = options.port // because lots of servers prefix with spaces to try to appear first state.name = (state.name || '').trim() - - if (!('connect' in state)) { - state.connect = '' + - (state.gameHost || this.options.host || this.options.address) + - ':' + - (state.gamePort || this.options.port) - } + state.connect = `${state.gameHost || options.host || options.address}:${state.gamePort || options.port}` state.ping = this.shortestRTT + delete state.gameHost delete state.gamePort this.logger.debug(log => { - log('Size of players array: ' + state.players.length) - log('Size of bots array: ' + state.bots.length) + log('Size of players array:', state.players.length) + log('Size of bots array:', state.bots.length) }) return state @@ -104,19 +101,20 @@ export default class Core extends EventEmitter { async run (/** Results */ state) {} /** Param can be a time in ms, or a promise (which will be timed) */ - registerRtt (param) { - if (param.then) { - const start = Date.now() - param.then(() => { - const end = Date.now() - const rtt = end - start - this.registerRtt(rtt) - }).catch(() => {}) - } else { - this.logger.debug('Registered RTT: ' + param + 'ms') - if (this.shortestRTT === 0 || param < this.shortestRTT) { - this.shortestRTT = param + async registerRtt (param) { + try { + if (param instanceof Promise) { + const start = Date.now() + await param + await this.registerRtt(Date.now() - start) + } else { + this.logger.debug(`Registered RTT: ${param}ms`) + if (this.shortestRTT === 0 || param < this.shortestRTT) { + this.shortestRTT = param + } } + } catch (error) { + this.logger.debug(`Error in promise: ${error}`) } } @@ -164,8 +162,10 @@ export default class Core extends EventEmitter { */ async withTcp (fn, port) { this.usedTcp = true + const { options, logger } = this const address = this.options.address - if (!port) port = this.options.port + port ??= options.port + this.assertValidPort(port) let socket, connectionTimeout @@ -176,28 +176,29 @@ export default class Core extends EventEmitter { // Prevent unhandled 'error' events from dumping straight to console socket.on('error', () => {}) - this.logger.debug(log => { - this.logger.debug(address + ':' + port + ' TCP Connecting') + logger.debug(log => { + logger.debug(address + ':' + port + ' TCP Connecting') const writeHook = socket.write socket.write = (...args) => { log(address + ':' + port + ' TCP-->') log(debugDump(args[0])) writeHook.apply(socket, args) } + socket.on('error', e => log('TCP Error:', e)) socket.on('close', () => log('TCP Closed')) socket.on('data', (data) => { - log(address + ':' + port + ' <--TCP') + log(`${address}:${port} <--TCP`) log(data) }) - socket.on('ready', () => log(address + ':' + port + ' TCP Connected')) + socket.on('ready', () => log(`${address}:${port} TCP Connected`)) }) const connectionPromise = new Promise((resolve, reject) => { socket.on('ready', resolve) socket.on('close', () => reject(new Error('TCP Connection Refused'))) }) - this.registerRtt(connectionPromise) + await this.registerRtt(connectionPromise) connectionTimeout = Promises.createTimeout(this.options.socketTimeout, 'TCP Opening') await Promise.race([ connectionPromise, @@ -206,8 +207,8 @@ export default class Core extends EventEmitter { ]) return await fn(socket) } finally { - socket && socket.destroy() - connectionTimeout && connectionTimeout.cancel() + socket?.destroy() + connectionTimeout?.cancel() } } @@ -221,7 +222,7 @@ export default class Core extends EventEmitter { async tcpSend (socket, buffer, ondata) { let timeout try { - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, _reject) => { let received = Buffer.from([]) const onData = (data) => { received = Buffer.concat([received, data]) @@ -237,7 +238,7 @@ export default class Core extends EventEmitter { timeout = Promises.createTimeout(this.options.socketTimeout, 'TCP') return await Promise.race([promise, timeout, this.abortedPromise]) } finally { - timeout && timeout.cancel() + timeout?.cancel() } } @@ -249,8 +250,7 @@ export default class Core extends EventEmitter { * @template T */ async udpSend (buffer, onPacket, onTimeout) { - const address = this.options.address - const port = this.options.port + const { address, port, debug, socketTimeout } = this.options this.assertValidPort(port) if (typeof buffer === 'string') buffer = Buffer.from(buffer, 'binary') @@ -264,19 +264,14 @@ export default class Core extends EventEmitter { let socketCallback let timeout + try { const promise = new Promise((resolve, reject) => { const start = Date.now() - let end = null socketCallback = (fromAddress, fromPort, buffer) => { try { - if (fromAddress !== address) return - if (fromPort !== port) return - if (end === null) { - end = Date.now() - const rtt = end - start - this.registerRtt(rtt) - } + if (fromAddress !== address || fromPort !== port) return + this.registerRtt(Date.now() - start) const result = onPacket(buffer) if (result !== undefined) { this.logger.debug('UDP send finished by callback') @@ -286,30 +281,24 @@ export default class Core extends EventEmitter { reject(e) } } - socket.addCallback(socketCallback, this.options.debug) + socket.addCallback(socketCallback, debug) }) - timeout = Promises.createTimeout(this.options.socketTimeout, 'UDP') - const wrappedTimeout = new Promise((resolve, reject) => { - timeout.catch((e) => { - this.logger.debug('UDP timeout detected') - if (onTimeout) { - try { - const result = onTimeout() - if (result !== undefined) { - this.logger.debug('UDP timeout resolved by callback') - resolve(result) - return - } - } catch (e) { - reject(e) - } + timeout = Promises.createTimeout(socketTimeout, 'UDP') + const wrappedTimeout = Promise.resolve(timeout).catch((e) => { + this.logger.debug('UDP timeout detected') + if (onTimeout) { + const result = onTimeout() + if (result !== undefined) { + this.logger.debug('UDP timeout resolved by callback') + return result } - reject(e) - }) + } + throw e }) + return await Promise.race([promise, wrappedTimeout, this.abortedPromise]) } finally { - timeout && timeout.cancel() + timeout?.cancel() socketCallback && socket.removeCallback(socketCallback) } } @@ -344,7 +333,7 @@ export default class Core extends EventEmitter { }) return await Promise.race([wrappedPromise, this.abortedPromise]) } finally { - requestPromise && requestPromise.cancel() + requestPromise?.cancel() } } } diff --git a/protocols/gamespy2.js b/protocols/gamespy2.js index a4a47ca..ab13567 100644 --- a/protocols/gamespy2.js +++ b/protocols/gamespy2.js @@ -111,26 +111,23 @@ export default class gamespy2 extends Core { const units = [] while (!reader.done()) { const unit = {} - for (let iField = 0; iField < fields.length; iField++) { - let key = fields[iField] + for (let index = 0; index < fields.length; index++) { + let key = fields[index] let value = reader.string() - if (!value && iField === 0) return units + if (!value && index === 0) return units this.logger.debug('value:' + value) - if (key === 'player_') key = 'name' - else if (key === 'score_') key = 'score' - else if (key === 'deaths_') key = 'deaths' - else if (key === 'ping_') key = 'ping' - else if (key === 'team_') key = 'team' - else if (key === 'kills_') key = 'kills' + + // many fields end with "_" + if (key.endsWith('_')) { + key = key.slice(0, -1) + } + + if (key === 'player') key = 'name' else if (key === 'team_t') key = 'name' else if (key === 'tickets_t') key = 'tickets' - if ( - key === 'score' || key === 'deaths' || - key === 'ping' || key === 'team' || - key === 'kills' || key === 'tickets' - ) { + if (['score', 'deaths', 'ping', 'team', 'kills', 'tickets'].includes(key)) { if (value === '') continue value = parseInt(value) } diff --git a/protocols/gtasao.js b/protocols/gtasao.js new file mode 100644 index 0000000..cb43f77 --- /dev/null +++ b/protocols/gtasao.js @@ -0,0 +1,8 @@ +import samp from './samp.js' + +export default class gtasao extends samp { + constructor() { + super() + this.isOmp = true + } +} diff --git a/protocols/index.js b/protocols/index.js index 8156718..cb34d55 100644 --- a/protocols/index.js +++ b/protocols/index.js @@ -19,6 +19,7 @@ import gamespy2 from './gamespy2.js' import gamespy3 from './gamespy3.js' import geneshift from './geneshift.js' import goldsrc from './goldsrc.js' +import gtasao from './gtasao.js' import hexen2 from './hexen2.js' import jc2mp from './jc2mp.js' import kspdmp from './kspdmp.js' @@ -58,7 +59,7 @@ import theisleevrima from './theisleevrima.js' export { armagetron, ase, asa, assettocorsa, battlefield, buildandshoot, cs2d, discord, doom3, eco, epic, factorio, farmingsimulator, ffow, - fivem, gamespy1, gamespy2, gamespy3, geneshift, goldsrc, hexen2, jc2mp, kspdmp, mafia2mp, mafia2online, minecraft, + fivem, gamespy1, gamespy2, gamespy3, geneshift, goldsrc, gtasao, hexen2, jc2mp, kspdmp, mafia2mp, mafia2online, minecraft, minecraftbedrock, minecraftvanilla, mumble, mumbleping, nadeo, openttd, palworld, quake1, quake2, quake3, rfactor, samp, savage2, starmade, starsiege, teamspeak2, teamspeak3, terraria, tribes1, tribes1master, unreal2, ut3, valve, vcmp, ventrilo, warsow, eldewrito, beammpmaster, beammp, dayz, theisleevrima diff --git a/protocols/samp.js b/protocols/samp.js index 12fdea1..9daa78b 100644 --- a/protocols/samp.js +++ b/protocols/samp.js @@ -7,6 +7,7 @@ export default class samp extends Core { this.magicHeader = 'SAMP' this.responseMagicHeader = null this.isVcmp = false + this.isOmp = false } async run (state) { @@ -40,13 +41,14 @@ export default class samp extends Core { // read players // don't even bother if > 100 players, because the server won't respond if (state.numplayers < 100) { - if (this.isVcmp) { + if (this.isVcmp || this.isOmp) { const reader = await this.sendPacket('c', true) if (reader !== null) { const playerCount = reader.uint(2) for (let i = 0; i < playerCount; i++) { const player = {} player.name = reader.pascalString(1) + player.score = reader.int(4) state.players.push(player) } } diff --git a/protocols/teamspeak2.js b/protocols/teamspeak2.js index a2acbde..a5456c1 100644 --- a/protocols/teamspeak2.js +++ b/protocols/teamspeak2.js @@ -18,6 +18,7 @@ export default class teamspeak2 extends Core { const value = equals === -1 ? '' : line.substring(equals + 1) state.raw[key] = value } + if ('server_name' in state.raw) state.name = state.raw.server_name } {