diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e872e..74d0e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### 3.0.3 +* Greatly improve gamespy1 protocol, with additional error handling and xserverquery support. + ### 3.0.2 * Fix player name extraction for Unreal Tournament (1999) and possibly other gamespy1 games. diff --git a/package.json b/package.json index cc51ee7..f80e7a0 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ ], "main": "lib/index.js", "author": "GameDig Contributors", - "version": "3.0.2", + "version": "3.0.3", "repository": { "type": "git", "url": "https://github.com/gamedig/node-gamedig.git" diff --git a/protocols/gamespy1.js b/protocols/gamespy1.js index d1d4934..64330f9 100644 --- a/protocols/gamespy1.js +++ b/protocols/gamespy1.js @@ -1,5 +1,33 @@ const Core = require('./core'); +const stringKeys = new Set([ + 'website', + 'gametype', + 'gamemode', + 'player' +]); + +function normalizeEntry([key,value]) { + key = key.toLowerCase(); + const split = key.split('_'); + let keyType; + if (split.length === 2 && !isNaN(parseInt(split[1]))) { + keyType = split[0]; + } else { + keyType = key; + } + if (!stringKeys.has(keyType) && !keyType.includes('name')) { + if (value.toLowerCase() === 'true') { + value = true; + } else if (value.toLowerCase() === 'false') { + value = false; + } else if (!isNaN(parseInt(value))) { + value = parseInt(value); + } + } + return [key,value]; +} + class Gamespy1 extends Core { constructor() { super(); @@ -8,75 +36,80 @@ class Gamespy1 extends Core { } async run(state) { - { - const data = await this.sendPacket('info'); - state.raw = data; - if ('hostname' in state.raw) state.name = state.raw.hostname; - if ('mapname' in state.raw) state.map = state.raw.mapname; - if (this.trueTest(state.raw.password)) state.password = true; - if ('maxplayers' in state.raw) state.maxplayers = parseInt(state.raw.maxplayers); - if ('hostport' in state.raw) state.gamePort = parseInt(state.raw.hostport); - } - { - const data = await this.sendPacket('rules'); - state.raw.rules = data; - } - { - const data = await this.sendPacket('players'); - const playersById = {}; - const teamNamesById = {}; - for (const ident of Object.keys(data)) { - const split = ident.split('_'); - let key = split[0]; - const id = split[1]; - let value = data[ident]; + const raw = await this.sendPacket('\\status\\xserverquery'); + // Convert all keys to lowercase and normalize value types + const data = Object.fromEntries(Object.entries(raw).map(entry => normalizeEntry(entry))); + state.raw = data; + if ('hostname' in data) state.name = data.hostname; + if ('mapname' in data) state.map = data.mapname; + if (this.trueTest(data.password)) state.password = true; + if ('maxplayers' in data) state.maxplayers = parseInt(data.maxplayers); + if ('hostport' in data) state.gamePort = parseInt(data.hostport); + const teamOffByOne = data.gameid === 'bf1942'; + const playersById = {}; + const teamNamesById = {}; + for (const ident of Object.keys(data)) { + const split = ident.split('_'); + if (split.length !== 2) continue; + let key = split[0].toLowerCase(); + const id = parseInt(split[1]); + if (isNaN(id)) continue; + let value = data[ident]; + + delete data[ident]; + + if (key !== 'team' && key.startsWith('team')) { + // Info about a team if (key === 'teamname') { teamNamesById[id] = value; } else { - if (!(id in playersById)) playersById[id] = {}; - if (key === 'playername') { - key = 'name'; - } else if (key === 'player') { - key = 'name'; - } else if (key === 'team' && !isNaN(parseInt(value))) { - key = 'teamId'; - value = parseInt(value); - } else if (key === 'score' || key === 'ping' || key === 'deaths' || key === 'kills' || key === 'frags') { - value = parseInt(value); - } - playersById[id][key] = value; + // other team info which we don't track + } + } else { + // Info about a player + if (!(id in playersById)) playersById[id] = {}; + if (key === 'playername' || key === 'player') { + key = 'name'; + } + if (key === 'team' && !isNaN(parseInt(value))) { + key = 'teamId'; + value = parseInt(value) + (teamOffByOne ? -1 : 0); + } + if (key !== 'name' && !isNaN(parseInt(value))) { + value = parseInt(value); + } + playersById[id][key] = value; + } + } + state.raw.teams = teamNamesById; + + const players = Object.values(playersById); + + const seenHashes = new Set(); + for (const player of players) { + // Some servers (bf1942) report the same player multiple times (bug?) + // Ignore these duplicates + if (player.keyhash) { + if (seenHashes.has(player.keyhash)) { + this.logger.debug("Rejected player with hash " + player.keyhash + " (Duplicate keyhash)"); + continue; + } else { + seenHashes.add(player.keyhash); } } - state.raw.teams = teamNamesById; - const players = Object.values(playersById); - - const seenHashes = new Set(); - for (const player of players) { - // Some servers (bf1942) report the same player multiple times (bug?) - // Ignore these duplicates - if (player.keyhash) { - if (seenHashes.has(player.keyhash)) { - this.logger.debug("Rejected player with hash " + player.keyhash + " (Duplicate keyhash)"); - continue; - } else { - seenHashes.add(player.keyhash); - } + // Convert player's team ID to team name if possible + if (player.hasOwnProperty('teamId')) { + if (Object.keys(teamNamesById).length) { + player.team = teamNamesById[player.teamId] || ''; + } else { + player.team = player.teamId; + delete player.teamId; } - - // Convert player's team ID to team name if possible - if (player.hasOwnProperty('teamId')) { - if (Object.keys(teamNamesById).length) { - player.team = teamNamesById[player.teamId - 1] || ''; - } else { - player.team = player.teamId; - delete player.teamId; - } - } - - state.players.push(player); } + + state.players.push(player); } } @@ -86,7 +119,7 @@ class Gamespy1 extends Core { const parts = new Set(); let maxPartNum = 0; - return await this.udpSend('\\'+type+'\\', buffer => { + return await this.udpSend(type, buffer => { const reader = this.reader(buffer); const str = reader.string(buffer.length); const split = str.split('\\');