gamedigz 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GAMES_LIST.md +453 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/gamedig.js +79 -0
- package/lib/DnsResolver.js +76 -0
- package/lib/GlobalUdpSocket.js +69 -0
- package/lib/HexUtil.js +20 -0
- package/lib/Logger.js +45 -0
- package/lib/Promises.js +18 -0
- package/lib/ProtocolResolver.js +7 -0
- package/lib/QueryRunner.js +95 -0
- package/lib/Results.js +32 -0
- package/lib/game-resolver.js +17 -0
- package/lib/gamedig.js +23 -0
- package/lib/games.js +2747 -0
- package/lib/index.js +5 -0
- package/lib/reader.js +172 -0
- package/package.json +74 -0
- package/protocols/armagetron.js +65 -0
- package/protocols/asa.js +12 -0
- package/protocols/ase.js +45 -0
- package/protocols/assettocorsa.js +40 -0
- package/protocols/battlefield.js +162 -0
- package/protocols/beammp.js +32 -0
- package/protocols/beammpmaster.js +17 -0
- package/protocols/buildandshoot.js +55 -0
- package/protocols/core.js +349 -0
- package/protocols/cs2d.js +65 -0
- package/protocols/dayz.js +196 -0
- package/protocols/discord.js +29 -0
- package/protocols/doom3.js +148 -0
- package/protocols/eco.js +20 -0
- package/protocols/eldewrito.js +21 -0
- package/protocols/epic.js +95 -0
- package/protocols/ffow.js +38 -0
- package/protocols/fivem.js +33 -0
- package/protocols/gamespy1.js +181 -0
- package/protocols/gamespy2.js +144 -0
- package/protocols/gamespy3.js +197 -0
- package/protocols/geneshift.js +46 -0
- package/protocols/goldsrc.js +8 -0
- package/protocols/hexen2.js +14 -0
- package/protocols/index.js +61 -0
- package/protocols/jc2mp.js +16 -0
- package/protocols/kspdmp.js +28 -0
- package/protocols/mafia2mp.js +41 -0
- package/protocols/mafia2online.js +9 -0
- package/protocols/minecraft.js +102 -0
- package/protocols/minecraftbedrock.js +72 -0
- package/protocols/minecraftvanilla.js +87 -0
- package/protocols/mumble.js +39 -0
- package/protocols/mumbleping.js +24 -0
- package/protocols/nadeo.js +86 -0
- package/protocols/openttd.js +127 -0
- package/protocols/quake1.js +9 -0
- package/protocols/quake2.js +88 -0
- package/protocols/quake3.js +24 -0
- package/protocols/rfactor.js +69 -0
- package/protocols/samp.js +102 -0
- package/protocols/savage2.js +25 -0
- package/protocols/starmade.js +67 -0
- package/protocols/starsiege.js +10 -0
- package/protocols/teamspeak2.js +71 -0
- package/protocols/teamspeak3.js +69 -0
- package/protocols/terraria.js +24 -0
- package/protocols/tribes1.js +153 -0
- package/protocols/tribes1master.js +80 -0
- package/protocols/unreal2.js +150 -0
- package/protocols/ut3.js +45 -0
- package/protocols/valve.js +455 -0
- package/protocols/vcmp.js +10 -0
- package/protocols/ventrilo.js +237 -0
- package/protocols/warsow.js +13 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import Core from './core.js'
|
|
2
|
+
|
|
3
|
+
export default class doom3 extends Core {
|
|
4
|
+
constructor () {
|
|
5
|
+
super()
|
|
6
|
+
this.encoding = 'latin1'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async run (state) {
|
|
10
|
+
const body = await this.udpSend('\xff\xffgetInfo\x00PiNGPoNg\x00', packet => {
|
|
11
|
+
const reader = this.reader(packet)
|
|
12
|
+
const header = reader.uint(2)
|
|
13
|
+
if (header !== 0xffff) return
|
|
14
|
+
const header2 = reader.string()
|
|
15
|
+
if (header2 !== 'infoResponse') return
|
|
16
|
+
const challengePart1 = reader.string(4)
|
|
17
|
+
if (challengePart1 !== 'PiNG') return
|
|
18
|
+
// some doom3 implementations only return the first 4 bytes of the challenge
|
|
19
|
+
const challengePart2 = reader.string(4)
|
|
20
|
+
if (challengePart2 !== 'PoNg') reader.skip(-4)
|
|
21
|
+
return reader.rest()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
let reader = this.reader(body)
|
|
25
|
+
const protoVersion = reader.uint(4)
|
|
26
|
+
state.raw.protocolVersion = (protoVersion >> 16) + '.' + (protoVersion & 0xffff)
|
|
27
|
+
|
|
28
|
+
// some doom implementations send us a packet size here, some don't (etqw does this)
|
|
29
|
+
// we can tell if this is a packet size, because the third and fourth byte will be 0 (no packets are that massive)
|
|
30
|
+
reader.skip(2)
|
|
31
|
+
const packetContainsSize = (reader.uint(2) === 0)
|
|
32
|
+
reader.skip(-4)
|
|
33
|
+
|
|
34
|
+
if (packetContainsSize) {
|
|
35
|
+
const size = reader.uint(4)
|
|
36
|
+
this.logger.debug('Received packet size: ' + size)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
while (!reader.done()) {
|
|
40
|
+
const key = reader.string()
|
|
41
|
+
let value = this.stripColors(reader.string())
|
|
42
|
+
if (key === 'si_map') {
|
|
43
|
+
value = value.replace('maps/', '')
|
|
44
|
+
value = value.replace('.entities', '')
|
|
45
|
+
}
|
|
46
|
+
if (!key) break
|
|
47
|
+
state.raw[key] = value
|
|
48
|
+
this.logger.debug(key + '=' + value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isEtqw = state.raw.gamename && state.raw.gamename.toLowerCase().includes('etqw')
|
|
52
|
+
|
|
53
|
+
const rest = reader.rest()
|
|
54
|
+
let playerResult = this.attemptPlayerParse(rest, isEtqw, false, false, false)
|
|
55
|
+
if (!playerResult) playerResult = this.attemptPlayerParse(rest, isEtqw, true, false, false)
|
|
56
|
+
if (!playerResult) playerResult = this.attemptPlayerParse(rest, isEtqw, true, true, true)
|
|
57
|
+
if (!playerResult) {
|
|
58
|
+
throw new Error('Unable to find a suitable parse strategy for player list')
|
|
59
|
+
}
|
|
60
|
+
let players;
|
|
61
|
+
[players, reader] = playerResult
|
|
62
|
+
|
|
63
|
+
state.numplayers = players.length
|
|
64
|
+
for (const player of players) {
|
|
65
|
+
if (!player.ping || player.typeflag) { state.bots.push(player) } else { state.players.push(player) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
state.raw.osmask = reader.uint(4)
|
|
69
|
+
if (isEtqw) {
|
|
70
|
+
state.raw.ranked = reader.uint(1)
|
|
71
|
+
state.raw.timeleft = reader.uint(4)
|
|
72
|
+
state.raw.gamestate = reader.uint(1)
|
|
73
|
+
state.raw.servertype = reader.uint(1)
|
|
74
|
+
// 0 = regular, 1 = tv
|
|
75
|
+
if (state.raw.servertype === 0) {
|
|
76
|
+
state.raw.interestedClients = reader.uint(1)
|
|
77
|
+
} else if (state.raw.servertype === 1) {
|
|
78
|
+
state.raw.connectedClients = reader.uint(4)
|
|
79
|
+
state.raw.maxClients = reader.uint(4)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (state.raw.si_name) state.name = state.raw.si_name
|
|
84
|
+
if (state.raw.si_map) state.map = state.raw.si_map
|
|
85
|
+
if (state.raw.si_maxplayers) state.maxplayers = parseInt(state.raw.si_maxplayers)
|
|
86
|
+
if (state.raw.si_maxPlayers) state.maxplayers = parseInt(state.raw.si_maxPlayers)
|
|
87
|
+
if (state.raw.si_usepass === '1') state.password = true
|
|
88
|
+
if (state.raw.si_needPass === '1') state.password = true
|
|
89
|
+
if (this.options.port === 27733) state.gamePort = 3074 // etqw has a different query and game port
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
attemptPlayerParse (rest, isEtqw, hasClanTag, hasClanTagPos, hasTypeFlag) {
|
|
93
|
+
this.logger.debug('starting player parse attempt:')
|
|
94
|
+
this.logger.debug('isEtqw: ' + isEtqw)
|
|
95
|
+
this.logger.debug('hasClanTag: ' + hasClanTag)
|
|
96
|
+
this.logger.debug('hasClanTagPos: ' + hasClanTagPos)
|
|
97
|
+
this.logger.debug('hasTypeFlag: ' + hasTypeFlag)
|
|
98
|
+
const reader = this.reader(rest)
|
|
99
|
+
let lastId = -1
|
|
100
|
+
const players = []
|
|
101
|
+
while (true) {
|
|
102
|
+
this.logger.debug('---')
|
|
103
|
+
if (reader.done()) {
|
|
104
|
+
this.logger.debug('* aborting attempt, overran buffer *')
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const player = {}
|
|
108
|
+
player.id = reader.uint(1)
|
|
109
|
+
this.logger.debug('id: ' + player.id)
|
|
110
|
+
if (player.id <= lastId || player.id > 0x20) {
|
|
111
|
+
this.logger.debug('* aborting attempt, invalid player id *')
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
lastId = player.id
|
|
115
|
+
if (player.id === 0x20) {
|
|
116
|
+
this.logger.debug('* player parse successful *')
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
player.ping = reader.uint(2)
|
|
120
|
+
this.logger.debug('ping: ' + player.ping)
|
|
121
|
+
if (!isEtqw) {
|
|
122
|
+
player.rate = reader.uint(4)
|
|
123
|
+
this.logger.debug('rate: ' + player.rate)
|
|
124
|
+
}
|
|
125
|
+
player.name = this.stripColors(reader.string())
|
|
126
|
+
this.logger.debug('name: ' + player.name)
|
|
127
|
+
if (hasClanTag) {
|
|
128
|
+
if (hasClanTagPos) {
|
|
129
|
+
const clanTagPos = reader.uint(1)
|
|
130
|
+
this.logger.debug('clanTagPos: ' + clanTagPos)
|
|
131
|
+
}
|
|
132
|
+
player.clantag = this.stripColors(reader.string())
|
|
133
|
+
this.logger.debug('clan tag: ' + player.clantag)
|
|
134
|
+
}
|
|
135
|
+
if (hasTypeFlag) {
|
|
136
|
+
player.typeflag = reader.uint(1)
|
|
137
|
+
this.logger.debug('type flag: ' + player.typeflag)
|
|
138
|
+
}
|
|
139
|
+
players.push(player)
|
|
140
|
+
}
|
|
141
|
+
return [players, reader]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stripColors (str) {
|
|
145
|
+
// uses quake 3 color codes
|
|
146
|
+
return str.replace(/\^(X.{6}|.)/g, '')
|
|
147
|
+
}
|
|
148
|
+
}
|
package/protocols/eco.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Core from './core.js'
|
|
2
|
+
|
|
3
|
+
export default class eco extends Core {
|
|
4
|
+
async run (state) {
|
|
5
|
+
if (!this.options.port) this.options.port = 3001
|
|
6
|
+
|
|
7
|
+
const request = await this.request({
|
|
8
|
+
url: `http://${this.options.address}:${this.options.port}/frontpage`,
|
|
9
|
+
responseType: 'json'
|
|
10
|
+
})
|
|
11
|
+
const serverInfo = request.Info
|
|
12
|
+
|
|
13
|
+
state.name = serverInfo.Description
|
|
14
|
+
state.numplayers = serverInfo.OnlinePlayers;
|
|
15
|
+
state.maxplayers = serverInfo.TotalPlayers
|
|
16
|
+
state.password = serverInfo.HasPassword
|
|
17
|
+
state.gamePort = serverInfo.GamePort
|
|
18
|
+
state.raw = serverInfo
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import Core from './core.js'
|
|
2
|
+
|
|
3
|
+
export default class eldewrito extends Core {
|
|
4
|
+
async run (state) {
|
|
5
|
+
const json = await this.request({
|
|
6
|
+
url: 'http://' + this.options.address + ':' + this.options.port,
|
|
7
|
+
responseType: 'json'
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
for (const one of json.players) {
|
|
11
|
+
state.players.push({ name: one.name, team: one.team })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
state.name = json.name
|
|
15
|
+
state.map = json.map
|
|
16
|
+
state.maxplayers = json.maxPlayers
|
|
17
|
+
state.connect = this.options.address + ':' + json.port
|
|
18
|
+
|
|
19
|
+
state.raw = json
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import Core from './core.js'
|
|
2
|
+
|
|
3
|
+
export default class Epic extends Core {
|
|
4
|
+
constructor () {
|
|
5
|
+
super()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* To get information about game servers using Epic's EOS, you need some credentials to authenticate using OAuth2.
|
|
9
|
+
*
|
|
10
|
+
* https://dev.epicgames.com/docs/web-api-ref/authentication
|
|
11
|
+
*
|
|
12
|
+
* These credentials can be provided by the game developers or extracted from the game's files.
|
|
13
|
+
*/
|
|
14
|
+
this.clientId = null
|
|
15
|
+
this.clientSecret = null
|
|
16
|
+
this.deploymentId = null
|
|
17
|
+
this.epicApi = 'https://api.epicgames.dev'
|
|
18
|
+
this.accessToken = null
|
|
19
|
+
|
|
20
|
+
// Don't use the tcp ping probing
|
|
21
|
+
this.usedTcp = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run (state) {
|
|
25
|
+
await this.getAccessToken()
|
|
26
|
+
await this.queryInfo(state)
|
|
27
|
+
await this.cleanup(state)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getAccessToken () {
|
|
31
|
+
this.logger.debug('Requesting acess token ...')
|
|
32
|
+
|
|
33
|
+
const url = `${this.epicApi}/auth/v1/oauth/token`
|
|
34
|
+
const body = `grant_type=client_credentials&deployment_id=${this.deploymentId}`
|
|
35
|
+
const headers = {
|
|
36
|
+
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`,
|
|
37
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.logger.debug(`POST: ${url}`)
|
|
41
|
+
const response = await this.request({ url, body, headers, method: 'POST', responseType: 'json' })
|
|
42
|
+
|
|
43
|
+
this.accessToken = response.access_token
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async queryInfo (state) {
|
|
47
|
+
const url = `${this.epicApi}/matchmaking/v1/${this.deploymentId}/filter`
|
|
48
|
+
const body = {
|
|
49
|
+
criteria: [
|
|
50
|
+
{
|
|
51
|
+
key: 'attributes.ADDRESS_s',
|
|
52
|
+
op: 'EQUAL',
|
|
53
|
+
value: this.options.address
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
const headers = {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
Accept: 'application/json',
|
|
60
|
+
Authorization: `Bearer ${this.accessToken}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.logger.debug(`POST: ${url}`)
|
|
64
|
+
const response = await this.request({ url, json: body, headers, method: 'POST', responseType: 'json' })
|
|
65
|
+
|
|
66
|
+
// Epic returns a list of sessions, we need to find the one with the desired port.
|
|
67
|
+
const hasDesiredPort = (session) => session.attributes.ADDRESSBOUND_s === `0.0.0.0:${this.options.port}` ||
|
|
68
|
+
session.attributes.ADDRESSBOUND_s === `${this.options.address}:${this.options.port}`
|
|
69
|
+
|
|
70
|
+
const desiredServer = response.sessions.find(hasDesiredPort)
|
|
71
|
+
|
|
72
|
+
if (!desiredServer) {
|
|
73
|
+
throw new Error('Server not found')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
state.name = desiredServer.attributes.CUSTOMSERVERNAME_s
|
|
77
|
+
state.map = desiredServer.attributes.MAPNAME_s
|
|
78
|
+
state.password = desiredServer.attributes.SERVERPASSWORD_b
|
|
79
|
+
state.numplayers = desiredServer.totalPlayers
|
|
80
|
+
state.maxplayers = desiredServer.settings.maxPublicPlayers
|
|
81
|
+
|
|
82
|
+
for (const player of desiredServer.publicPlayers) {
|
|
83
|
+
state.players.push({
|
|
84
|
+
name: player.name,
|
|
85
|
+
raw: player
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
state.raw = desiredServer
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async cleanup (state) {
|
|
93
|
+
this.accessToken = null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import valve from './valve.js'
|
|
2
|
+
|
|
3
|
+
export default class ffow extends valve {
|
|
4
|
+
constructor () {
|
|
5
|
+
super()
|
|
6
|
+
this.byteorder = 'be'
|
|
7
|
+
this.legacyChallenge = true
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async queryInfo (state) {
|
|
11
|
+
this.logger.debug('Requesting ffow info ...')
|
|
12
|
+
const b = await this.sendPacket(
|
|
13
|
+
0x46,
|
|
14
|
+
'LSQ',
|
|
15
|
+
0x49
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const reader = this.reader(b)
|
|
19
|
+
state.raw.protocol = reader.uint(1)
|
|
20
|
+
state.name = reader.string()
|
|
21
|
+
state.map = reader.string()
|
|
22
|
+
state.raw.mod = reader.string()
|
|
23
|
+
state.raw.gamemode = reader.string()
|
|
24
|
+
state.raw.description = reader.string()
|
|
25
|
+
state.raw.version = reader.string()
|
|
26
|
+
state.gamePort = reader.uint(2)
|
|
27
|
+
state.numplayers = reader.uint(1)
|
|
28
|
+
state.maxplayers = reader.uint(1)
|
|
29
|
+
state.raw.listentype = String.fromCharCode(reader.uint(1))
|
|
30
|
+
state.raw.environment = String.fromCharCode(reader.uint(1))
|
|
31
|
+
state.password = !!reader.uint(1)
|
|
32
|
+
state.raw.secure = reader.uint(1)
|
|
33
|
+
state.raw.averagefps = reader.uint(1)
|
|
34
|
+
state.raw.round = reader.uint(1)
|
|
35
|
+
state.raw.maxrounds = reader.uint(1)
|
|
36
|
+
state.raw.timeleft = reader.uint(2)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import quake2 from './quake2.js'
|
|
2
|
+
|
|
3
|
+
export default class fivem extends quake2 {
|
|
4
|
+
constructor () {
|
|
5
|
+
super()
|
|
6
|
+
this.sendHeader = 'getinfo xxx'
|
|
7
|
+
this.responseHeader = 'infoResponse'
|
|
8
|
+
this.encoding = 'utf8'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async run (state) {
|
|
12
|
+
await super.run(state)
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
const json = await this.request({
|
|
16
|
+
url: 'http://' + this.options.address + ':' + this.options.port + '/info.json',
|
|
17
|
+
responseType: 'json'
|
|
18
|
+
})
|
|
19
|
+
state.raw.info = json
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
const json = await this.request({
|
|
24
|
+
url: 'http://' + this.options.address + ':' + this.options.port + '/players.json',
|
|
25
|
+
responseType: 'json'
|
|
26
|
+
})
|
|
27
|
+
state.raw.players = json
|
|
28
|
+
for (const player of json) {
|
|
29
|
+
state.players.push({ name: player.name, ping: player.ping })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import Core from './core.js'
|
|
2
|
+
|
|
3
|
+
const stringKeys = new Set([
|
|
4
|
+
'website',
|
|
5
|
+
'gametype',
|
|
6
|
+
'gamemode',
|
|
7
|
+
'player'
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
function normalizeEntry ([key, value]) {
|
|
11
|
+
key = key.toLowerCase()
|
|
12
|
+
const split = key.split('_')
|
|
13
|
+
let keyType = key
|
|
14
|
+
|
|
15
|
+
if (split.length === 2 && !isNaN(Number(split[1]))) {
|
|
16
|
+
keyType = split[0]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!stringKeys.has(keyType) && !keyType.includes('name')) { // todo! the latter check might be problematic, fails on key "name_tag_distance_scope"
|
|
20
|
+
if (value.toLowerCase() === 'true') {
|
|
21
|
+
value = true
|
|
22
|
+
} else if (value.toLowerCase() === 'false') {
|
|
23
|
+
value = false
|
|
24
|
+
} else if (value.length && !isNaN(Number(value))) {
|
|
25
|
+
value = Number(value)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return [key, value]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default class gamespy1 extends Core {
|
|
33
|
+
constructor () {
|
|
34
|
+
super()
|
|
35
|
+
this.encoding = 'latin1'
|
|
36
|
+
this.byteorder = 'be'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async run (state) {
|
|
40
|
+
const raw = await this.sendPacket('\\status\\xserverquery')
|
|
41
|
+
// Convert all keys to lowercase and normalize value types
|
|
42
|
+
const data = Object.fromEntries(Object.entries(raw).map(entry => normalizeEntry(entry)))
|
|
43
|
+
state.raw = data
|
|
44
|
+
if ('hostname' in data) state.name = data.hostname
|
|
45
|
+
if ('mapname' in data) state.map = data.mapname
|
|
46
|
+
if (this.trueTest(data.password)) state.password = true
|
|
47
|
+
if ('maxplayers' in data) state.maxplayers = Number(data.maxplayers)
|
|
48
|
+
if ('hostport' in data) state.gamePort = Number(data.hostport)
|
|
49
|
+
|
|
50
|
+
const teamOffByOne = data.gamename === 'bfield1942'
|
|
51
|
+
const playersById = {}
|
|
52
|
+
const teamNamesById = {}
|
|
53
|
+
for (const ident of Object.keys(data)) {
|
|
54
|
+
const split = ident.split('_')
|
|
55
|
+
if (split.length !== 2) continue
|
|
56
|
+
let key = split[0].toLowerCase()
|
|
57
|
+
const id = Number(split[1])
|
|
58
|
+
if (isNaN(id)) continue
|
|
59
|
+
let value = data[ident]
|
|
60
|
+
|
|
61
|
+
delete data[ident]
|
|
62
|
+
|
|
63
|
+
if (key !== 'team' && key.startsWith('team')) {
|
|
64
|
+
// Info about a team
|
|
65
|
+
if (key === 'teamname') {
|
|
66
|
+
teamNamesById[id] = value
|
|
67
|
+
} else {
|
|
68
|
+
// other team info which we don't track
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// Info about a player
|
|
72
|
+
if (!(id in playersById)) playersById[id] = {}
|
|
73
|
+
|
|
74
|
+
if (key === 'playername' || key === 'player') {
|
|
75
|
+
key = 'name'
|
|
76
|
+
}
|
|
77
|
+
if (key === 'team' && !isNaN(value)) { // todo! technically, this NaN check isn't needed.
|
|
78
|
+
key = 'teamId'
|
|
79
|
+
value += teamOffByOne ? -1 : 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
playersById[id][key] = value
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
state.raw.teams = teamNamesById
|
|
86
|
+
|
|
87
|
+
const players = Object.values(playersById)
|
|
88
|
+
|
|
89
|
+
const seenHashes = new Set()
|
|
90
|
+
for (const player of players) {
|
|
91
|
+
// Some servers (bf1942) report the same player multiple times (bug?)
|
|
92
|
+
// Ignore these duplicates
|
|
93
|
+
if (player.keyhash) {
|
|
94
|
+
if (seenHashes.has(player.keyhash)) {
|
|
95
|
+
this.logger.debug('Rejected player with hash ' + player.keyhash + ' (Duplicate keyhash)')
|
|
96
|
+
continue
|
|
97
|
+
} else {
|
|
98
|
+
seenHashes.add(player.keyhash)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Convert player's team ID to team name if possible
|
|
103
|
+
if (Object.prototype.hasOwnProperty.call(player, 'teamId')) {
|
|
104
|
+
if (Object.keys(teamNamesById).length) {
|
|
105
|
+
player.team = teamNamesById[player.teamId] || ''
|
|
106
|
+
} else {
|
|
107
|
+
player.team = player.teamId
|
|
108
|
+
delete player.teamId
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
state.players.push(player)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
state.numplayers = state.players.length
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async sendPacket (type) {
|
|
119
|
+
let receivedQueryId
|
|
120
|
+
const output = {}
|
|
121
|
+
const parts = new Set()
|
|
122
|
+
let maxPartNum = 0
|
|
123
|
+
|
|
124
|
+
return await this.udpSend(type, buffer => {
|
|
125
|
+
const reader = this.reader(buffer)
|
|
126
|
+
const str = reader.string(buffer.length)
|
|
127
|
+
const split = str.split('\\')
|
|
128
|
+
split.shift()
|
|
129
|
+
const data = {}
|
|
130
|
+
while (split.length) {
|
|
131
|
+
const key = split.shift()
|
|
132
|
+
const value = split.shift() || ''
|
|
133
|
+
data[key] = value
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let queryId, partNum
|
|
137
|
+
const partFinal = ('final' in data)
|
|
138
|
+
if (data.queryid) {
|
|
139
|
+
const split = data.queryid.split('.')
|
|
140
|
+
if (split.length >= 2) {
|
|
141
|
+
partNum = Number(split[1])
|
|
142
|
+
}
|
|
143
|
+
queryId = split[0]
|
|
144
|
+
}
|
|
145
|
+
delete data.final
|
|
146
|
+
delete data.queryid
|
|
147
|
+
this.logger.debug('Received part num=' + partNum + ' queryId=' + queryId + ' final=' + partFinal)
|
|
148
|
+
|
|
149
|
+
if (queryId) {
|
|
150
|
+
if (receivedQueryId && receivedQueryId !== queryId) {
|
|
151
|
+
this.logger.debug('Rejected packet (Wrong query ID)')
|
|
152
|
+
return
|
|
153
|
+
} else if (!receivedQueryId) {
|
|
154
|
+
receivedQueryId = queryId
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!partNum) {
|
|
158
|
+
partNum = parts.size
|
|
159
|
+
this.logger.debug('No part number received (assigned #' + partNum + ')')
|
|
160
|
+
}
|
|
161
|
+
if (parts.has(partNum)) {
|
|
162
|
+
this.logger.debug('Rejected packet (Duplicate part)')
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
parts.add(partNum)
|
|
166
|
+
if (partFinal) {
|
|
167
|
+
maxPartNum = partNum
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.logger.debug('Received part #' + partNum + ' of ' + (maxPartNum || '?'))
|
|
171
|
+
for (const i of Object.keys(data)) {
|
|
172
|
+
output[i] = data[i]
|
|
173
|
+
}
|
|
174
|
+
if (maxPartNum && parts.size === maxPartNum) {
|
|
175
|
+
this.logger.debug('Received all parts')
|
|
176
|
+
this.logger.debug(output)
|
|
177
|
+
return output
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import Core from './core.js'
|
|
2
|
+
|
|
3
|
+
export default class gamespy2 extends Core {
|
|
4
|
+
constructor () {
|
|
5
|
+
super()
|
|
6
|
+
this.encoding = 'latin1'
|
|
7
|
+
this.byteorder = 'be'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async run (state) {
|
|
11
|
+
// Parse info
|
|
12
|
+
{
|
|
13
|
+
const body = await this.sendPacket([0xff, 0, 0])
|
|
14
|
+
const reader = this.reader(body)
|
|
15
|
+
while (!reader.done()) {
|
|
16
|
+
const key = reader.string()
|
|
17
|
+
const value = reader.string()
|
|
18
|
+
if (!key) break
|
|
19
|
+
state.raw[key] = value
|
|
20
|
+
}
|
|
21
|
+
if ('hostname' in state.raw) state.name = state.raw.hostname
|
|
22
|
+
if ('mapname' in state.raw) state.map = state.raw.mapname
|
|
23
|
+
if (this.trueTest(state.raw.password)) state.password = true
|
|
24
|
+
if ('maxplayers' in state.raw) state.maxplayers = parseInt(state.raw.maxplayers)
|
|
25
|
+
if ('hostport' in state.raw) state.gamePort = parseInt(state.raw.hostport)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Parse players
|
|
29
|
+
{
|
|
30
|
+
const body = await this.sendPacket([0, 0xff, 0])
|
|
31
|
+
const reader = this.reader(body)
|
|
32
|
+
for (const rawPlayer of this.readFieldData(reader)) {
|
|
33
|
+
state.players.push(rawPlayer)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if ('numplayers' in state.raw) state.numplayers = parseInt(state.raw.numplayers)
|
|
37
|
+
else state.numplayers = state.players.length
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse teams
|
|
41
|
+
{
|
|
42
|
+
const body = await this.sendPacket([0, 0, 0xff])
|
|
43
|
+
const reader = this.reader(body)
|
|
44
|
+
state.raw.teams = this.readFieldData(reader)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Special case for america's army 1 and 2
|
|
48
|
+
// both use gamename = "armygame"
|
|
49
|
+
if (state.raw.gamename === 'armygame') {
|
|
50
|
+
const stripColor = (str) => {
|
|
51
|
+
// uses unreal 2 color codes
|
|
52
|
+
return str.replace(/\x1b...|[\x00-\x1a]/g, '')
|
|
53
|
+
}
|
|
54
|
+
state.name = stripColor(state.name)
|
|
55
|
+
state.map = stripColor(state.map)
|
|
56
|
+
for (const key of Object.keys(state.raw)) {
|
|
57
|
+
if (typeof state.raw[key] === 'string') {
|
|
58
|
+
state.raw[key] = stripColor(state.raw[key])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const player of state.players) {
|
|
62
|
+
if (!('name' in player)) continue
|
|
63
|
+
player.name = stripColor(player.name)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async sendPacket (type) {
|
|
69
|
+
const request = Buffer.concat([
|
|
70
|
+
Buffer.from([0xfe, 0xfd, 0x00]), // gamespy2
|
|
71
|
+
Buffer.from([0x00, 0x00, 0x00, 0x01]), // ping ID
|
|
72
|
+
Buffer.from(type)
|
|
73
|
+
])
|
|
74
|
+
return await this.udpSend(request, buffer => {
|
|
75
|
+
const reader = this.reader(buffer)
|
|
76
|
+
const header = reader.uint(1)
|
|
77
|
+
if (header !== 0) return
|
|
78
|
+
const pingId = reader.uint(4)
|
|
79
|
+
if (pingId !== 1) return
|
|
80
|
+
return reader.rest()
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
readFieldData (reader) {
|
|
85
|
+
reader.uint(1) // always 0
|
|
86
|
+
const count = reader.uint(1) // number of rows in this data
|
|
87
|
+
|
|
88
|
+
// some games omit the count byte entirely if it's 0 or at random (like americas army)
|
|
89
|
+
// Luckily, count should always be <64, and ascii characters will typically be >64,
|
|
90
|
+
// so we can detect this.
|
|
91
|
+
if (count > 64) {
|
|
92
|
+
reader.skip(-1)
|
|
93
|
+
this.logger.debug('Detected missing count byte, rewinding by 1')
|
|
94
|
+
} else {
|
|
95
|
+
this.logger.debug('Detected row count: ' + count)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.logger.debug(() => 'Reading fields, starting at: ' + reader.rest())
|
|
99
|
+
|
|
100
|
+
const fields = []
|
|
101
|
+
while (!reader.done()) {
|
|
102
|
+
const field = reader.string()
|
|
103
|
+
if (!field) break
|
|
104
|
+
fields.push(field)
|
|
105
|
+
this.logger.debug('field:' + field)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!fields.length) return []
|
|
109
|
+
|
|
110
|
+
const units = []
|
|
111
|
+
while (!reader.done()) {
|
|
112
|
+
const unit = {}
|
|
113
|
+
for (let iField = 0; iField < fields.length; iField++) {
|
|
114
|
+
let key = fields[iField]
|
|
115
|
+
let value = reader.string()
|
|
116
|
+
if (!value && iField === 0) return units
|
|
117
|
+
|
|
118
|
+
this.logger.debug('value:' + value)
|
|
119
|
+
if (key === 'player_') key = 'name'
|
|
120
|
+
else if (key === 'score_') key = 'score'
|
|
121
|
+
else if (key === 'deaths_') key = 'deaths'
|
|
122
|
+
else if (key === 'ping_') key = 'ping'
|
|
123
|
+
else if (key === 'team_') key = 'team'
|
|
124
|
+
else if (key === 'kills_') key = 'kills'
|
|
125
|
+
else if (key === 'team_t') key = 'name'
|
|
126
|
+
else if (key === 'tickets_t') key = 'tickets'
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
key === 'score' || key === 'deaths' ||
|
|
130
|
+
key === 'ping' || key === 'team' ||
|
|
131
|
+
key === 'kills' || key === 'tickets'
|
|
132
|
+
) {
|
|
133
|
+
if (value === '') continue
|
|
134
|
+
value = parseInt(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
unit[key] = value
|
|
138
|
+
}
|
|
139
|
+
units.push(unit)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return units
|
|
143
|
+
}
|
|
144
|
+
}
|