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.
Files changed (73) hide show
  1. package/GAMES_LIST.md +453 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/bin/gamedig.js +79 -0
  5. package/lib/DnsResolver.js +76 -0
  6. package/lib/GlobalUdpSocket.js +69 -0
  7. package/lib/HexUtil.js +20 -0
  8. package/lib/Logger.js +45 -0
  9. package/lib/Promises.js +18 -0
  10. package/lib/ProtocolResolver.js +7 -0
  11. package/lib/QueryRunner.js +95 -0
  12. package/lib/Results.js +32 -0
  13. package/lib/game-resolver.js +17 -0
  14. package/lib/gamedig.js +23 -0
  15. package/lib/games.js +2747 -0
  16. package/lib/index.js +5 -0
  17. package/lib/reader.js +172 -0
  18. package/package.json +74 -0
  19. package/protocols/armagetron.js +65 -0
  20. package/protocols/asa.js +12 -0
  21. package/protocols/ase.js +45 -0
  22. package/protocols/assettocorsa.js +40 -0
  23. package/protocols/battlefield.js +162 -0
  24. package/protocols/beammp.js +32 -0
  25. package/protocols/beammpmaster.js +17 -0
  26. package/protocols/buildandshoot.js +55 -0
  27. package/protocols/core.js +349 -0
  28. package/protocols/cs2d.js +65 -0
  29. package/protocols/dayz.js +196 -0
  30. package/protocols/discord.js +29 -0
  31. package/protocols/doom3.js +148 -0
  32. package/protocols/eco.js +20 -0
  33. package/protocols/eldewrito.js +21 -0
  34. package/protocols/epic.js +95 -0
  35. package/protocols/ffow.js +38 -0
  36. package/protocols/fivem.js +33 -0
  37. package/protocols/gamespy1.js +181 -0
  38. package/protocols/gamespy2.js +144 -0
  39. package/protocols/gamespy3.js +197 -0
  40. package/protocols/geneshift.js +46 -0
  41. package/protocols/goldsrc.js +8 -0
  42. package/protocols/hexen2.js +14 -0
  43. package/protocols/index.js +61 -0
  44. package/protocols/jc2mp.js +16 -0
  45. package/protocols/kspdmp.js +28 -0
  46. package/protocols/mafia2mp.js +41 -0
  47. package/protocols/mafia2online.js +9 -0
  48. package/protocols/minecraft.js +102 -0
  49. package/protocols/minecraftbedrock.js +72 -0
  50. package/protocols/minecraftvanilla.js +87 -0
  51. package/protocols/mumble.js +39 -0
  52. package/protocols/mumbleping.js +24 -0
  53. package/protocols/nadeo.js +86 -0
  54. package/protocols/openttd.js +127 -0
  55. package/protocols/quake1.js +9 -0
  56. package/protocols/quake2.js +88 -0
  57. package/protocols/quake3.js +24 -0
  58. package/protocols/rfactor.js +69 -0
  59. package/protocols/samp.js +102 -0
  60. package/protocols/savage2.js +25 -0
  61. package/protocols/starmade.js +67 -0
  62. package/protocols/starsiege.js +10 -0
  63. package/protocols/teamspeak2.js +71 -0
  64. package/protocols/teamspeak3.js +69 -0
  65. package/protocols/terraria.js +24 -0
  66. package/protocols/tribes1.js +153 -0
  67. package/protocols/tribes1master.js +80 -0
  68. package/protocols/unreal2.js +150 -0
  69. package/protocols/ut3.js +45 -0
  70. package/protocols/valve.js +455 -0
  71. package/protocols/vcmp.js +10 -0
  72. package/protocols/ventrilo.js +237 -0
  73. 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
+ }
@@ -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
+ }